fix(cso): install Rails gems on one thread so exports stop failing (v1.91.31.0) (#3056)

Ruby's File.umask getter sets the process umask to 0 for an instant, and
RubyGems calls it for every extracted file. With bundle install --jobs 2, a
directory created by the other install thread in that window came out 0777,
and the prepared-tree export correctly refused it ('prepared tree contains a
publicly writable directory'). The offline install now runs --jobs 1.

preparedExportRejection() also accepts the helper's real output: its
top-level handler prints the bare message and exits 70, which CI reported as
'unrecognized helper failure'.
This commit is contained in:
Garry Tan authored and GitHub committed 2026-10-06 20:13:27 +00:00
1 parent 55f8151fbe
commit 7697fb2981
9 files changed
+43 -10

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## [1.91.31.0] - 2026-10-06
**Rails runtime qualification no longer fails at random when exporting the prepared app.**
Main run 37504391826 passed 9 of its 10 native qualification rows. The Rails amd64 row failed with `PREPARED_EXPORT_REJECTED (exit 70: unrecognized helper failure)`. That failure had appeared once locally before.
- **Bundler installs on one thread.** Ruby's `File.umask` getter briefly sets the process umask to 0 and then restores it, and RubyGems calls it for every file it extracts. The Rails offline step ran `bundle install --jobs 2`, so a directory created by the second install thread at that moment came out 0777. The export then refused the tree, correctly, with "prepared tree contains a publicly writable directory". The install now runs with `--jobs 1`, and the export gate is unchanged.
- Extracting the fixture's 69 gems on two threads in the staged image left 0777 directories in 20 of 20 rounds; extracting them on one thread left none in 20 rounds.
- Real `bundle install --jobs 2` runs of the fixture left them in 3 of 16 standalone installs and 1 of 18 full preparation runs.
- At the app container's 0.85 CPU share, one thread installs in the same time as two.
- **The rejection reason is readable.** The helper's top-level handler prints the bare error message and exits 70, but the parser only accepted Bun's `error: ` prefix. It now accepts both forms and also reads errno-style messages, while still dropping any path.
## [1.91.30.0] - 2026-10-05 ## [1.91.30.0] - 2026-10-05
**A failed eval now says what happened, with the evidence and the next command.** **A failed eval now says what happened, with the evidence and the next command.**
+1 -1
View File
@@ -1 +1 @@
1.91.30.0 1.91.31.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.30.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.91.31.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+9
View File
@@ -126,6 +126,15 @@ reason, such as `prepared tree contains a hard-linked file`, or the filesystem
errno and syscall. It never includes target paths. Nothing retries. Treat a errno and syscall. It never includes target paths. Nothing retries. Treat a
recurrence as evidence for a defect, not as a flake to rerun. recurrence as evidence for a defect, not as a flake to rerun.
The Rails offline install runs `bundle install --jobs 1`. Ruby's
`File.umask` getter sets the process umask to 0 for an instant, and RubyGems
calls it for every file it extracts. With two Bundler install threads, a
directory created by one thread during that instant comes out 0777, and the
export refuses the tree with `prepared tree contains a publicly writable
directory`. That refusal is correct, so the install runs on one thread rather
than the gate being relaxed. At the app role's 0.85 CPU share the second thread
saved no measurable time.
For hashed Python requirements resolved by pip, the broker records the requested For hashed Python requirements resolved by pip, the broker records the requested
index URL, allowed contacted hosts, and verified wheel hash. Pip's internal index URL, allowed contacted hosts, and verified wheel hash. Pip's internal
`files.pythonhosted.org` response URL is opaque to that TLS boundary, so `files.pythonhosted.org` response URL is opaque to that TLS boundary, so
+9 -6
View File
@@ -404,15 +404,18 @@ export function validateSingleContainerProcessOutput(output: string): void {
); );
} }
/** /**
* Names why the in-image export helper refused a prepared tree. Only the * Names why the in-image export helper refused a prepared tree. The helper's
* helper's fixed "prepared ..." messages and filesystem errno/syscall * top-level handler prints the error message alone and exits 70; an uncaught
* names pass through, so target-controlled paths never reach the error. * error prints it after "error: ". Only the helper's fixed "prepared ..."
* sentences and filesystem errno/syscall names pass through, so
* target-controlled paths never reach the error.
*/ */
export function preparedExportRejection(stderr: string): string { export function preparedExportRejection(stderr: string): string {
const reason = stderr.match(/^error: (prepared [A-Za-z ,-]+)$/m)?.[1]; const reason = stderr.match(/^(?:error: )?(prepared [A-Za-z ,-]+)$/m)?.[1];
if (reason) return reason; if (reason) return reason;
const errno = stderr.match(/^\s*code: "(E[A-Z]+)",?$/m)?.[1], const thrown = stderr.match(/^(?:error: )?(E[A-Z]+): [a-z ]+, ([a-z]+)\b/m),
syscall = stderr.match(/^\s*syscall: "([a-z]+)",?$/m)?.[1]; errno = thrown?.[1] ?? stderr.match(/^\s*code: "(E[A-Z]+)",?$/m)?.[1],
syscall = thrown?.[2] ?? stderr.match(/^\s*syscall: "([a-z]+)",?$/m)?.[1];
if (errno) return `filesystem error ${errno}${syscall ? ` during ${syscall}` : ''}`; if (errno) return `filesystem error ${errno}${syscall ? ` during ${syscall}` : ''}`;
return 'unrecognized helper failure'; return 'unrecognized helper failure';
} }
+5 -1
View File
@@ -1027,8 +1027,12 @@ function inspectRails(root: string, plan: PreparationPlan) {
BUNDLE_CACHE_PATH: '/archives', BUNDLE_CACHE_PATH: '/archives',
BUNDLE_USER_HOME: '/work/.cso-bundle', BUNDLE_USER_HOME: '/work/.cso-bundle',
}; };
// One install thread. Ruby's File.umask getter sets the process umask to 0
// for an instant, and RubyGems calls it for every extracted file, so a second
// Bundler thread creating a directory in that window leaves it 0777 and the
// prepared-tree export correctly refuses it.
plan.offline.push( plan.offline.push(
command('/usr/local/bin/bundle', ['install', '--local', '--jobs', '2', '--retry', '0'], '/work', env), command('/usr/local/bin/bundle', ['install', '--local', '--jobs', '1', '--retry', '0'], '/work', env),
); );
plan.registryHosts = ['rubygems.org', 'index.rubygems.org']; plan.registryHosts = ['rubygems.org', 'index.rubygems.org'];
const declared = railsDatabaseConfiguration(read(root, 'config/database.yml', true)); const declared = railsDatabaseConfiguration(read(root, 'config/database.yml', true));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.91.30", "version": "1.91.31",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+3
View File
@@ -195,6 +195,9 @@ describe('CSO prepared export rejection reason', () => {
.toBe('prepared tree contains a hard-linked file'); .toBe('prepared tree contains a hard-linked file');
expect(preparedExportRejection("ENOENT: no such file or directory, lstat '/work/secret-name'\n path: \"/work/secret-name\",\n syscall: \"lstat\",\n errno: -2,\n code: \"ENOENT\"\n")) expect(preparedExportRejection("ENOENT: no such file or directory, lstat '/work/secret-name'\n path: \"/work/secret-name\",\n syscall: \"lstat\",\n errno: -2,\n code: \"ENOENT\"\n"))
.toBe('filesystem error ENOENT during lstat'); .toBe('filesystem error ENOENT during lstat');
expect(preparedExportRejection('prepared tree contains a publicly writable directory\n')).toBe('prepared tree contains a publicly writable directory');
expect(preparedExportRejection("ENOENT: no such file or directory, lstat '/work/secret-name'\n")).toBe('filesystem error ENOENT during lstat');
expect(preparedExportRejection('prepared /work/secret-name\n')).toBe('unrecognized helper failure');
expect(preparedExportRejection('error: prepared /work/secret-name\n')).toBe('unrecognized helper failure'); expect(preparedExportRejection('error: prepared /work/secret-name\n')).toBe('unrecognized helper failure');
expect(preparedExportRejection('error: prepared tree contains a FIFO, socket, device, or other special object\n')).toBe('prepared tree contains a FIFO, socket, device, or other special object'); expect(preparedExportRejection('error: prepared tree contains a FIFO, socket, device, or other special object\n')).toBe('prepared tree contains a FIFO, socket, device, or other special object');
expect(preparedExportRejection('')).toBe('unrecognized helper failure'); expect(preparedExportRejection('')).toBe('unrecognized helper failure');
+2
View File
@@ -179,6 +179,8 @@ describe('CSO inert Rails preparation', () => {
expect(plan.inputs[1].platform).toBe('x86_64-linux-gnu'); expect(plan.inputs[0].integritySource).toBe('registry-on-acquisition'); expect(plan.inputs[1].platform).toBe('x86_64-linux-gnu'); expect(plan.inputs[0].integritySource).toBe('registry-on-acquisition');
expect(plan.runtimeRequirements).toEqual({ ruby: '3.3.6', bundler: '2.6.9' }); expect(plan.runtimeRequirements).toEqual({ ruby: '3.3.6', bundler: '2.6.9' });
expect(plan.offline[0].args).toContain('--local'); expect(plan.offline[0].env.BUNDLE_IGNORE_CONFIG).toBe('true'); expect(plan.offline[0].args).toContain('--local'); expect(plan.offline[0].env.BUNDLE_IGNORE_CONFIG).toBe('true');
// Parallel Bundler threads race RubyGems' File.umask calls and leave 0777 directories.
expect(plan.offline[0].args.slice(plan.offline[0].args.indexOf('--jobs'), plan.offline[0].args.indexOf('--jobs') + 2)).toEqual(['--jobs', '1']);
expect(plan.offline[0].env.GEM_HOME).toBeUndefined(); expect(plan.offline[0].env.GEM_PATH).toBeUndefined(); expect(plan.offline[0].env.GEM_HOME).toBeUndefined(); expect(plan.offline[0].env.GEM_PATH).toBeUndefined();
}); });
test('records lock checksums separately from newly acquired archive hashes', () => { test('records lock checksums separately from newly acquired archive hashes', () => {