Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/cli/src/utils/housekeeping/throttledOnce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,68 @@ describe('runThrottledOnce', () => {
expect(fs.statSync(markerPath).isDirectory()).toBe(true);
expect(fs.existsSync(lockPath)).toBe(false);
});

it('creates the lock directory when it does not exist yet', async () => {
const qwenDir = path.join(tempDir, 'qwen-dir');
const task = vi.fn(async () => {});

const result = await runThrottledOnce(
{
name: 'test',
markerPath: path.join(qwenDir, '.marker'),
lockPath: path.join(qwenDir, '.marker.lock'),
},
task,
);

expect(result).toEqual({ status: 'completed' });
expect(task).toHaveBeenCalledOnce();
const stat = fs.statSync(qwenDir);
expect(stat.isDirectory()).toBe(true);
// No group/other access, per the ~/.qwen/ convention. Asserting the
// absence of those bits rather than an exact mode keeps this umask-proof.
// On Windows mkdir's mode is a no-op and libuv duplicates owner bits to
// group/other, so only POSIX platforms can pin the 0o700 convention.
if (process.platform !== 'win32') {
expect(stat.mode & 0o077).toBe(0);
}
});

// Regression: this mkdir used to pass `recursive: true`. On a bind mount
// whose source directory was deleted, the mountpoint still stats as a
// directory but rejects creates with ENOENT, and Node's recursive mkdir
// retries the parent forever without ever settling its promise — wedging the
// housekeeping chain and spinning a core per orphaned CI sandbox container.
it('never asks for a recursive mkdir', async () => {
const task = vi.fn(async () => {});

await runThrottledOnce({ name: 'test', markerPath, lockPath }, task);

expect(fsPromises.mkdir).toHaveBeenCalled();
for (const [, options] of vi.mocked(fsPromises.mkdir).mock.calls) {
expect(options).not.toMatchObject({ recursive: true });
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
}
});

// Effect-shaped companion to the option-shape regression above: pin what
// the deleted-mount state actually does. The non-recursive mkdir cannot
// create a directory whose own parent is missing, and the lock open must
// surface that ENOENT on the first attempt — not settle as 'locked'
// (misread as contention by the scheduler), and never bootstrap the parent.
it('surfaces ENOENT instead of bootstrapping a missing parent', async () => {
const missingDir = path.join(tempDir, 'nope', 'nested');
const task = vi.fn(async () => {});

await expect(
runThrottledOnce(
{
name: 'test',
markerPath: path.join(missingDir, '.marker'),
lockPath: path.join(missingDir, '.marker.lock'),
},
task,
),
).rejects.toMatchObject({ code: 'ENOENT' });
expect(task).not.toHaveBeenCalled();
});
});
17 changes: 14 additions & 3 deletions packages/cli/src/utils/housekeeping/throttledOnce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,20 @@ export async function runThrottledOnce(
// matches the rest of the codebase's convention for ~/.qwen/ subdirs
// (e.g., file-token-storage.ts, sharedTokenManager.ts) so a slow main-app
// initialization doesn't get races us into creating a world-readable dir.
await mkdir(dirname(opts.lockPath), { recursive: true, mode: 0o700 }).catch(
() => {},
);
//
// Deliberately NOT recursive. On a bind mount whose source directory was
// deleted underneath us, the mountpoint still stats as a directory while
// creating entries inside it fails with ENOENT. Node's recursive mkdir reads
// that ENOENT as "parent is missing", creates the parent (EEXIST), confirms
// it is a directory, retries the leaf, gets ENOENT again — forever. The
// returned promise never settles, so the `.catch` below never runs and this
// `await` wedges the whole housekeeping chain for the life of the process.
// CI hit exactly that: orphaned e2e sandbox containers whose workspace had
// already been cleaned up each burned a core on ~2.5k failing mkdir/s.
//
// One level is all this needs: every caller puts lockPath directly in the
// global qwen dir, whose own parent is $HOME.
await mkdir(dirname(opts.lockPath), { mode: 0o700 }).catch(() => {});

const firstFreshForMs = await markerFreshForMs(
opts.markerPath,
Expand Down
Loading