From 663dce1cfd37d37f2e8c4338b7ff91da6bcaab84 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:49:40 -0400 Subject: [PATCH] =?UTF-8?q?fix(test):=20/proc=20is=20not=20a=20portable=20?= =?UTF-8?q?"unwritable=20path"=20=E2=80=94=20on=20Linux=20it=20HANGS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new stores used `/proc/nonexistent/...` to stand for a directory that cannot be written. On macOS there is no /proc, so `mkdirSync(recursive)` failed fast with ENOENT and the tests passed. On Linux the same call **hangs** — not throws, hangs, with no output, no exit and no timeout — which wedged the Test step of every Linux CI job while both macOS jobs finished in about 40 seconds. This branch had never met a Linux runner until the PR, so it was the first opportunity to find it. Reproduced in a node:22-slim container, bisected to the exact call, and fixed by using the pattern fs-cursor-store.test.ts already established and I should have followed: a real temp directory chmod'd to 0500, restored in a `finally`. Portable, and it exercises a genuine permission denial rather than a filesystem curiosity. Full suite now 740/740 on Linux as a non-root user, matching macOS. Worth stating for whoever writes the next store test: an "obviously bad path" fixture has to be bad in the same way on every platform the suite runs on, or it is testing the platform rather than the code. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- test/adapters/claude-session-swap.test.ts | 35 ++++++++++++++--------- test/adapters/fs-usage-store.test.ts | 24 +++++++++++++--- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/test/adapters/claude-session-swap.test.ts b/test/adapters/claude-session-swap.test.ts index 2768edb..bedf229 100644 --- a/test/adapters/claude-session-swap.test.ts +++ b/test/adapters/claude-session-swap.test.ts @@ -7,7 +7,7 @@ // the command that caused it. import test from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { readRawCredential, swapCredential } from '../../src/adapters/claude-session/swap.ts' @@ -169,20 +169,29 @@ test('a source with no login is refused, and nothing is written', async () => { }) test('an unwritable target is refused, and says nothing changed', async () => { + // A read-only PARENT, so the target cannot be created inside it. Not + // `/proc/nonexistent/...`: that path throws ENOENT on macOS but makes + // `mkdirSync(recursive)` HANG on Linux, which wedged the whole suite in CI + // the first time this branch met a Linux runner. const from = fresh() const fake = fakeSecurity({ [keychainService(from, env)]: BLOB }) - // A path whose parent cannot be created. - const result = swapCredential(from, '/proc/nonexistent/swisscode-target', { - env, - platform: 'darwin', - exec: fake.exec, - }) - assert.equal(result.ok, false) - assert.equal( - fake.calls.filter((c) => c.args[0] === 'delete-generic-password').length, - 0, - 'a failed write must never drop the target\'s existing login', - ) + const parent = fresh() + chmodSync(parent, 0o500) + try { + const result = swapCredential(from, join(parent, 'target'), { + env, + platform: 'darwin', + exec: fake.exec, + }) + assert.equal(result.ok, false) + assert.equal( + fake.calls.filter((c) => c.args[0] === 'delete-generic-password').length, + 0, + "a failed write must never drop the target's existing login", + ) + } finally { + chmodSync(parent, 0o700) + } }) test('the identity block is COPIED, or the swap is only half done', async () => { diff --git a/test/adapters/fs-usage-store.test.ts b/test/adapters/fs-usage-store.test.ts index 8cee4e8..220ff52 100644 --- a/test/adapters/fs-usage-store.test.ts +++ b/test/adapters/fs-usage-store.test.ts @@ -5,7 +5,7 @@ // fail the command that triggered it. import test from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SNAPSHOT_TTL_MS, createFsUsageStore } from '../../src/adapters/store/fs-usage-store.ts' @@ -87,9 +87,25 @@ test('a snapshot with nothing usable left in it reads as absent', () => { }) test('an unwritable directory does not throw — the measurement already happened', () => { - const store = createFsUsageStore({ dir: '/proc/nonexistent/swisscode' }) - assert.doesNotThrow(() => store.write({ remaining: { a: 1 }, checkedAt: Date.now() })) - assert.equal(store.read(), null) + // A REAL read-only directory, matching fs-cursor-store.test.ts. + // + // The first version of this used `/proc/nonexistent/...` as a path that + // "obviously cannot be written". On macOS there is no /proc, so it failed + // fast with ENOENT and the test passed. ON LINUX `mkdirSync(recursive)` + // AGAINST THAT PATH HANGS — not throws, hangs, with no output and no exit — + // which wedged the whole suite in CI. Portable fixtures only. + const parent = fresh() + const dir = join(parent, 'state') + mkdirSync(dir) + chmodSync(dir, 0o500) + const store = createFsUsageStore({ dir }) + try { + assert.doesNotThrow(() => store.write({ remaining: { a: 1 }, checkedAt: Date.now() })) + assert.equal(store.read(), null) + } finally { + // Restored so the temp tree can be cleaned up by whoever cleans /tmp. + chmodSync(dir, 0o700) + } }) test('the file holds no credential — only account names and figures', () => {