From 7697fb2981733186ce541789a56cda532f27587b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 6 Oct 2026 13:13:27 -0700 Subject: [PATCH] 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'. --- CHANGELOG.md | 12 ++++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/cso-release-qualification.md | 9 +++++++++ lib/cso/docker.ts | 15 +++++++++------ lib/cso/preparation.ts | 6 +++++- package.json | 2 +- test/cso-docker-mounts.test.ts | 3 +++ test/cso-preparation.test.ts | 2 ++ 9 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8977338b3..dacc75ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 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 **A failed eval now says what happened, with the evidence and the next command.** diff --git a/VERSION b/VERSION index 4609f2bd2..898f53d0e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.30.0 +1.91.31.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index aa4dfe875..ce3081603 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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 for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/cso-release-qualification.md b/docs/cso-release-qualification.md index 5af039395..32c81b98f 100644 --- a/docs/cso-release-qualification.md +++ b/docs/cso-release-qualification.md @@ -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 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 index URL, allowed contacted hosts, and verified wheel hash. Pip's internal `files.pythonhosted.org` response URL is opaque to that TLS boundary, so diff --git a/lib/cso/docker.ts b/lib/cso/docker.ts index 51ce2566b..55857ae22 100644 --- a/lib/cso/docker.ts +++ b/lib/cso/docker.ts @@ -404,15 +404,18 @@ export function validateSingleContainerProcessOutput(output: string): void { ); } /** - * Names why the in-image export helper refused a prepared tree. Only the - * helper's fixed "prepared ..." messages and filesystem errno/syscall - * names pass through, so target-controlled paths never reach the error. + * Names why the in-image export helper refused a prepared tree. The helper's + * top-level handler prints the error message alone and exits 70; an uncaught + * 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 { - 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; - const errno = stderr.match(/^\s*code: "(E[A-Z]+)",?$/m)?.[1], - syscall = stderr.match(/^\s*syscall: "([a-z]+)",?$/m)?.[1]; + const thrown = stderr.match(/^(?:error: )?(E[A-Z]+): [a-z ]+, ([a-z]+)\b/m), + 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}` : ''}`; return 'unrecognized helper failure'; } diff --git a/lib/cso/preparation.ts b/lib/cso/preparation.ts index 2a5563819..dff72d712 100644 --- a/lib/cso/preparation.ts +++ b/lib/cso/preparation.ts @@ -1027,8 +1027,12 @@ function inspectRails(root: string, plan: PreparationPlan) { BUNDLE_CACHE_PATH: '/archives', 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( - 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']; const declared = railsDatabaseConfiguration(read(root, 'config/database.yml', true)); diff --git a/package.json b/package.json index bb50eb2d7..ea6ff0e15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "license": "MIT", "type": "module", diff --git a/test/cso-docker-mounts.test.ts b/test/cso-docker-mounts.test.ts index fdcad2e43..af60ab1b2 100644 --- a/test/cso-docker-mounts.test.ts +++ b/test/cso-docker-mounts.test.ts @@ -195,6 +195,9 @@ describe('CSO prepared export rejection reason', () => { .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")) .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 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'); diff --git a/test/cso-preparation.test.ts b/test/cso-preparation.test.ts index 6cc8fc1b8..11bff5fd1 100644 --- a/test/cso-preparation.test.ts +++ b/test/cso-preparation.test.ts @@ -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.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'); + // 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(); }); test('records lock checksums separately from newly acquired archive hashes', () => {