Skip to content

Commit 8562c43

Browse files
committed
test: fix cross-platform Bun fixtures and CI isolation
1 parent 1b33025 commit 8562c43

9 files changed

Lines changed: 262 additions & 11 deletions

File tree

‎.github/workflows/bun-compatibility.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ on:
1717
- '.github/actions/upload-artifact/**'
1818
- '.github/workflows/bun-compatibility.yml'
1919
- 'scripts/backtest-bun*.py'
20+
- 'scripts/probe-bun-historical-linux.py'
2021
- 'scripts/bun-historical-shas.json'
2122
- 'crates/socket-patch-cli/tests/e2e_bun_lockb.rs'
2223
- 'crates/socket-patch-core/tests/fixtures/bun-lockb/**'
@@ -49,6 +50,7 @@ on:
4950
paths:
5051
- '.github/workflows/bun-compatibility.yml'
5152
- 'scripts/backtest-bun*.py'
53+
- 'scripts/probe-bun-historical-linux.py'
5254
- 'scripts/bun-historical-shas.json'
5355
- 'crates/socket-patch-cli/tests/e2e_bun_lockb.rs'
5456
- 'crates/socket-patch-core/tests/fixtures/bun-lockb/**'
@@ -129,6 +131,9 @@ jobs:
129131
needs: build
130132
strategy:
131133
fail-fast: false
134+
# Each job runs three cells against the public patch service. Bound
135+
# concurrent jobs so the full release matrix does not flood that API.
136+
max-parallel: 6
132137
matrix:
133138
os: [ubuntu-latest, macos-latest, windows-latest]
134139
# Every lock-format and behaviour boundary the CLI has to survive:
@@ -330,6 +335,7 @@ jobs:
330335
native-bun/captures/**/cli-output.json
331336
native-bun/captures/**/tree/**
332337
native-bun/captures/**/*.log
338+
native-bun/attempts/**
333339
retention-days: 14
334340

335341

@@ -371,6 +377,13 @@ jobs:
371377
--output native-binary/results \
372378
--jobs 2 "${versions[@]}"
373379
380+
- name: Diagnose historical Linux runtime failures
381+
if: failure() && runner.os == 'Linux'
382+
run: >-
383+
python3 scripts/probe-bun-historical-linux.py
384+
--tools native-binary/tools
385+
--output native-binary/results/linux-diagnostics
386+
374387
- name: Upload binary acceptance results
375388
if: always()
376389
uses: ./.github/actions/upload-artifact

‎crates/socket-patch-cli/tests/e2e_bun_lockb.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ fn require_success(output: Output, label: &str) -> Output {
4848
assert!(
4949
output.status.success(),
5050
"{label}: {:?}\nstdout: {}\nstderr: {}",
51-
output.status.code(),
51+
output.status,
5252
String::from_utf8_lossy(&output.stdout),
5353
String::from_utf8_lossy(&output.stderr)
5454
);
@@ -228,7 +228,11 @@ impl Fixture {
228228
.collect();
229229
let captured_fixture = std::env::var_os("SOCKET_PATCH_BUN_LOCKB_WRITER").is_none()
230230
&& major_minor.as_slice() >= [1, 2].as_slice();
231-
let temp = tempfile::tempdir().unwrap();
231+
// Windows runners keep the checkout on D: and the system tempdir on
232+
// C:. Bun workspace writers cannot relativize paths across those
233+
// drives; keep the fixture on the working drive, as the public matrix
234+
// does, while retaining the space/Unicode project path below.
235+
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
232236
let project = temp.path().join("binary project café");
233237
std::fs::create_dir_all(&project).unwrap();
234238
let dependencies = match shape {

‎crates/socket-patch-cli/tests/setup_invariants.rs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -942,11 +942,17 @@ fn setup_configures_npm_workspace_members() {
942942

943943
const GEMFILE_FIXTURE: &str = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'\n";
944944

945+
fn write_supported_gem_project(root: &Path) {
946+
write(&root.join("Gemfile"), GEMFILE_FIXTURE);
947+
// These tests exercise setup, not discovery of the host's Bundler version.
948+
write(&root.join("Gemfile.lock"), "BUNDLED WITH\n 2.7.2\n");
949+
}
950+
945951
#[test]
946952
fn setup_gem_dry_run_does_not_modify_gemfile() {
947953
let tmp = tempfile::tempdir().expect("tempdir");
948954
let gemfile = tmp.path().join("Gemfile");
949-
write(&gemfile, GEMFILE_FIXTURE);
955+
write_supported_gem_project(tmp.path());
950956

951957
let (code, stdout) = run_setup(tmp.path(), &["--dry-run"]);
952958
assert_eq!(code, 0, "dry-run should succeed; stdout=\n{stdout}");
@@ -969,7 +975,7 @@ fn setup_gem_dry_run_does_not_modify_gemfile() {
969975
#[test]
970976
fn setup_configures_gem_alongside_npm() {
971977
let tmp = tempfile::tempdir().expect("tempdir");
972-
write(&tmp.path().join("Gemfile"), GEMFILE_FIXTURE);
978+
write_supported_gem_project(tmp.path());
973979
write(
974980
&tmp.path().join("package.json"),
975981
r#"{ "name": "mixed", "version": "1.0.0" }
@@ -1030,7 +1036,7 @@ fn setup_gem_materialization_honors_manifest_path() {
10301036
// the assertion below could pass for the wrong reason (e.g. the warning
10311037
// vanishing for some unrelated change).
10321038
let control = tempfile::tempdir().expect("tempdir");
1033-
write(&control.path().join("Gemfile"), GEMFILE_FIXTURE);
1039+
write_supported_gem_project(control.path());
10341040
write(
10351041
&control.path().join(".socket/manifest.json"),
10361042
"not json {{{",
@@ -1046,7 +1052,7 @@ fn setup_gem_materialization_honors_manifest_path() {
10461052
// Same fixture, but the run is pointed at a valid manifest elsewhere. The
10471053
// nested apply must use it, so no materialization warning is emitted.
10481054
let tmp = tempfile::tempdir().expect("tempdir");
1049-
write(&tmp.path().join("Gemfile"), GEMFILE_FIXTURE);
1055+
write_supported_gem_project(tmp.path());
10501056
write(&tmp.path().join(".socket/manifest.json"), "not json {{{");
10511057
write(&tmp.path().join("custom/patches.json"), r#"{"patches":{}}"#);
10521058

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[
2-
"redirect_bun_lockb_unsupported"
2+
"redirect_bun_lockb_bytes_required"
33
]

‎docs/testing/bun-compatibility.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,15 @@ provenance); releases before 1.1.0 have no Windows binary. The `legacy-lockb`
309309
shape also needs Bun 1.1.38 (its baseline writer), fetched or reused the same
310310
way whenever a `legacy-lockb` cell applies to a requested release.
311311

312+
Public-service cells retry at most three times when a captured CLI response
313+
contains an explicit request transport error. Every retry recreates the project
314+
and its caches, and preserves the failed attempt's logs, result and captured
315+
tree under `attempts/`; the final row records `networkRetryAttempts`. Functional
316+
failures without a transport error are never retried. CI limits the public
317+
matrix to six concurrent jobs, with three cells per job. Each cell has its own
318+
temporary directory so historical Bun processes cannot collide while extracting
319+
identically named packages.
320+
312321
**Pinned versions:** 0.8.1, 1.0.0, 1.0.36, 1.1.0, 1.1.38 (binary lock),
313322
1.1.39 (first text lock, version 0), 1.1.43 (first `--lockfile-only`), 1.1.45
314323
(last version-0 writer), 1.2.0, 1.2.23, 1.3.0 (version 1), 1.3.9 / 1.3.10

‎scripts/backtest-bun-lockb.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def cell(pair):
8686
cwd=root, env=env, stdout=subprocess.PIPE,
8787
stderr=subprocess.STDOUT, timeout=600)
8888
log = result.stdout.decode(errors='replace')
89-
(output / f'{version}-writer-{writer_version}.log').write_text(log)
89+
(output / f'{version}-writer-{writer_version}.log').write_text(log, encoding='utf-8')
9090
row.update(extendedLayouts='SOCKET_PATCH_BUN_LOCKB_EXTENDED' in env,
9191
exitCode=result.returncode, passed=result.returncode == 0 and
9292
'3 passed;' in log and 'SKIP binary Bun E2E' not in log,

‎scripts/backtest-bun.py‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,43 @@ def save(path, data):
138138
path.write_text(json.dumps(data, indent=2) + '\n', encoding='utf-8')
139139

140140

141+
def has_transport_failure(value):
142+
"""Only explicit request transport errors qualify for a fresh-cell retry."""
143+
if isinstance(value, dict):
144+
return any(has_transport_failure(item) for item in value.values())
145+
if isinstance(value, list):
146+
return any(has_transport_failure(item) for item in value)
147+
return isinstance(value, str) and 'error sending request for url (' in value
148+
149+
150+
def retry_network_cell(run_case, job, root, attempts=3):
151+
"""Keep failed evidence and retry transient service failures from a clean tree."""
152+
name = '-'.join(job)
153+
case = root / 'captures' / name
154+
history = []
155+
for attempt in range(1, attempts + 1):
156+
row = run_case(job)
157+
if row['passed'] or not has_transport_failure(row) or attempt == attempts:
158+
if history:
159+
row['networkRetryAttempts'] = history
160+
save(case / 'result.json', row)
161+
return row
162+
evidence = root / 'attempts' / name / str(attempt)
163+
evidence.mkdir(parents=True, exist_ok=True)
164+
for source in case.iterdir():
165+
if source.is_file() and source.suffix in ('.log', '.json'):
166+
shutil.copy2(source, evidence / source.name)
167+
elif source.name == 'tree':
168+
shutil.copytree(source, evidence / source.name, dirs_exist_ok=True)
169+
history.append(dict(attempt=attempt, evidence=evidence.relative_to(root).as_posix(),
170+
failedChecks=[key for key, passed in row.get('checks', {}).items() if not passed]))
171+
# Remove the failed project's package-manager caches too. A retry is
172+
# another cold proof, never a continuation from partially written state.
173+
shutil.rmtree(case)
174+
print(f'{name}: request transport failed; retrying fresh cell ({attempt}/{attempts})', flush=True)
175+
time.sleep(5 * attempt)
176+
177+
141178
def run(command, cwd, env, log, required=True, timeout=180, tolerate_timeout=False):
142179
"""(exit code, combined output). A hang is an error — except for the
143180
digest-tamper installs (`tolerate_timeout`), where Bun 1.3.9 and 1.3.10
@@ -649,9 +686,13 @@ def backtest(job):
649686
bun = tools[version]
650687

651688
def env_for(binary, cache):
689+
temporary = case / 'tool-tmp'
690+
temporary.mkdir(exist_ok=True)
652691
return dict(base_env, PATH=str(binary.parent) + os.pathsep + base_env['PATH'],
653692
BUN_INSTALL_CACHE_DIR=str(case / cache),
654-
BUN_INSTALL=str(case / 'bun-home'))
693+
BUN_INSTALL=str(case / 'bun-home'),
694+
TMPDIR=str(temporary), TMP=str(temporary), TEMP=str(temporary),
695+
BUN_TMPDIR=str(temporary))
655696
env = env_for(bun, 'cache')
656697

657698
def cli_command(verb, run_mode):
@@ -875,7 +916,8 @@ def install(binary, label, flags=(), cache=None):
875916
if capture.exists():
876917
shutil.rmtree(capture)
877918
capture.mkdir()
878-
for name in [*files, 'bun.lock', 'bun.lockb', '.socket/manifest.json']:
919+
for name in [*files, 'bun.lock', 'bun.lockb', '.socket/manifest.json',
920+
'.socket/vendor/state.json', '.socket/vendor/redirect-state.json']:
879921
source = project / name
880922
if source.is_file():
881923
destination = capture / name
@@ -1004,7 +1046,7 @@ def install(binary, label, flags=(), cache=None):
10041046
return row
10051047

10061048
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool:
1007-
rows = list(pool.map(backtest, jobs))
1049+
rows = list(pool.map(lambda job: retry_network_cell(backtest, job, root), jobs))
10081050
save(root / 'summary.json', rows)
10091051
for version in args.versions:
10101052
mine = [r for r in rows if r['bun'] == version]
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""Bounded diagnostics for historical Linux Bun crashes before lockfile edits.
3+
4+
Run after a failed binary matrix. This never changes its acceptance result or
5+
downloads replacement tools. Each installed Bun gets a pristine package project
6+
and a 30-second total budget, including an optional strace reproduction.
7+
"""
8+
9+
import argparse
10+
import json
11+
import os
12+
from pathlib import Path
13+
import platform
14+
import shutil
15+
import signal
16+
import subprocess
17+
import tempfile
18+
import time
19+
20+
21+
def run(command, cwd, env, output, label, timeout):
22+
started = time.monotonic()
23+
row = {'command': [str(part) for part in command], 'timeoutSeconds': timeout}
24+
try:
25+
with (output / f'{label}.stdout').open('wb') as stdout, \
26+
(output / f'{label}.stderr').open('wb') as stderr:
27+
process = subprocess.Popen(command, cwd=cwd, env=env, stdin=subprocess.DEVNULL,
28+
stdout=stdout, stderr=stderr, start_new_session=True)
29+
try:
30+
process.wait(timeout=timeout)
31+
except subprocess.TimeoutExpired:
32+
row['timedOut'] = True
33+
os.killpg(process.pid, signal.SIGKILL)
34+
process.wait(timeout=5)
35+
row['returnCode'] = process.returncode
36+
if process.returncode < 0:
37+
row['signal'] = signal.Signals(-process.returncode).name
38+
except (OSError, subprocess.SubprocessError) as error:
39+
row['error'] = str(error)
40+
row['elapsedSeconds'] = round(time.monotonic() - started, 3)
41+
(output / f'{label}.json').write_text(json.dumps(row, indent=2) + '\n')
42+
print(json.dumps({'probe': label, **row}), flush=True)
43+
return row
44+
45+
46+
def fixture(root):
47+
# Match the acceptance test's Unicode/spaced path and isolated cache/temp.
48+
project = root / 'binary project café'
49+
project.mkdir(parents=True)
50+
(project / 'package.json').write_text(json.dumps({
51+
'name': 'native-binary-bun', 'version': '1.0.0', 'private': True,
52+
'dependencies': {'minimist': '1.2.2', 'is-number': '7.0.0'},
53+
}) + '\n')
54+
(project / 'bunfig.toml').write_text('[install]\nsaveTextLockfile = false\n')
55+
env = {key: value for key, value in os.environ.items()
56+
if not key.startswith(('BUN_', 'SOCKET_', 'NPM_CONFIG_', 'npm_config_'))}
57+
for key, name in {
58+
'HOME': 'home', 'XDG_CONFIG_HOME': 'config', 'XDG_CACHE_HOME': 'cache',
59+
'BUN_INSTALL': 'bun-home', 'BUN_INSTALL_CACHE_DIR': 'bun-cache',
60+
'TMPDIR': 'temporary', 'TMP': 'temporary', 'TEMP': 'temporary',
61+
'BUN_TMPDIR': 'temporary',
62+
}.items():
63+
path = root / name
64+
path.mkdir(exist_ok=True)
65+
env[key] = str(path)
66+
return project, env
67+
68+
69+
def main():
70+
parser = argparse.ArgumentParser(description=__doc__)
71+
parser.add_argument('--tools', type=Path, required=True)
72+
parser.add_argument('--output', type=Path, required=True)
73+
args = parser.parse_args()
74+
output = args.output.resolve()
75+
output.mkdir(parents=True, exist_ok=True)
76+
if platform.system() != 'Linux':
77+
print('Historical Linux diagnostics require Linux; no probes run.')
78+
return
79+
80+
context = {'platform': platform.platform(), 'strace': shutil.which('strace')}
81+
for name in ['kernel/io_uring_disabled', 'kernel/io_uring_group', 'vm/mmap_rnd_bits']:
82+
path = Path('/proc/sys') / name
83+
context[name] = path.read_text().strip() if path.exists() else None
84+
for name in ['status', 'limits']:
85+
path = Path('/proc/self') / name
86+
(output / f'process-{name}.txt').write_text(path.read_text())
87+
(output / 'context.json').write_text(json.dumps(context, indent=2) + '\n')
88+
for executable, arguments in [('uname', ['-a']), ('lscpu', []), ('ldd', ['--version'])]:
89+
if program := shutil.which(executable):
90+
run([program, *arguments], output, os.environ.copy(), output, executable, 5)
91+
92+
summary = []
93+
for version in ['0.5.9', '0.6.7', '0.6.8']:
94+
matches = sorted((args.tools / version).glob('bun-linux-*/bun'))
95+
if not matches:
96+
summary.append({'version': version, 'error': 'downloaded Bun executable missing'})
97+
continue
98+
bun = matches[0].resolve()
99+
deadline = time.monotonic() + 30
100+
with tempfile.TemporaryDirectory(prefix=f'bun-linux-probe-{version}-', dir=output) as temporary:
101+
root = Path(temporary)
102+
project, env = fixture(root / 'pristine')
103+
plain = run([bun, 'install', '--ignore-scripts'], project, env, output,
104+
f'{version}-pristine', min(10, deadline - time.monotonic()))
105+
row = {'version': version, 'pristine': plain}
106+
if plain.get('returnCode') != 0 and context['strace']:
107+
project, env = fixture(root / 'traced')
108+
row['strace'] = run([
109+
context['strace'], '-f', '-tt', '-s', '160', '-o',
110+
output / f'{version}.strace', bun, 'install', '--ignore-scripts',
111+
], project, env, output, f'{version}-traced', max(1, deadline - time.monotonic()))
112+
summary.append(row)
113+
(output / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n')
114+
115+
116+
if __name__ == '__main__':
117+
main()

0 commit comments

Comments
 (0)