mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
55f8151fbe
commit
7697fb2981
9 files changed
+43
-10
No files matched your search
@@ -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,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,
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user