Skip to content

Commit e62cdd8

Browse files
thymikeeApex by Callstack
andcommitted
fix(host-kit): a reclaim decides in place, and one helper owns giving the lock back
Rename-aside made a reclaim a two-step transaction: move the abandoned directory out from under the lock path, judge it there, and move it back if it turned out to belong to somebody else. Between those steps the lock path is simply absent, and a contender polling the path reads that as free: it claims the path and publishes, and the rename back returns `ENOTEMPTY` into a `catch {}`. The judged directory ends up nobody's, and the one check meant to notice the swap — inode and device — can be answered by a recycled inode with a recycled token. Removal happens in place now, behind `mkdir <lock>.reclaim`: - nobody else is judging while we are, so the path is never emptied out from under a contender; - the judgement is re-made from what is on disk before anything is removed: same inode, same directory mtime, no live owner inside, and the record either carrying the judged claim token or still absent. A directory created a moment ago cannot answer for one abandoned past its grace; - a directory whose record was read goes as that claim's property. One with no record is emptied and `rmdir`'d, so contents nobody attributed to a claim are never destroyed. `releaseProcessLock` lost its recursive removal for the same reason: it unlinks the record it verified and removes the directory only while empty. The callers were each answering "which of two failures do I report?" by hand, and four answered with `finally`: `runner-artifact.ts`, `runner-cache.ts`, `managed-allocation/src/store.ts` and `store-lock.ts`. There an unverified release spoke over the task's own failure with `ownerReleaseUnverified`, which describes a lock and not the work. All four now call `withProcessLock({ acquire, task })`, which releases best-effort when the task failed and strictly when it did not. `managed agent-browser setup gives the lock back on every path out` pins a second bug that shape was hiding: setup returned early when the package was already installed, skipping the only `await release()` in the function and leaving the lock for the stale-clear path to notice five seconds later. `runner-device-set.ts` hands its release across a request boundary and `snapshot-source/host.ts` acquires in the background, so neither is a task-shaped caller. `swift-cache.ts` keeps swallowing a release error on success on purpose: a compiled helper should not fail because a lock lingered. Co-authored-by: Apex by Callstack <noreply@callstack.com>
1 parent 9ccc2bc commit e62cdd8

14 files changed

Lines changed: 526 additions & 302 deletions

File tree

packages/host-kit/src/file.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,9 @@ export {
1212
openVerifiedFileForTruncate,
1313
} from './internal/verified-file.ts';
1414
export { expandUserHomePath, resolveUserPath } from './internal/path-resolution.ts';
15-
export { acquireProcessLock, type ProcessLockOwner } from './internal/process-lock.ts';
15+
export {
16+
acquireProcessLock,
17+
withProcessLock,
18+
type ProcessLockOwner,
19+
type ProcessLockRelease,
20+
} from './internal/process-lock.ts';

packages/host-kit/src/internal/process-lock.test.ts

Lines changed: 183 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ vi.mock('./host-process.ts', async (importOriginal) => {
1111
return { ...actual, isProcessZombie: (pid: number) => zombiePids.has(pid) };
1212
});
1313

14-
import { acquireProcessLock, type ProcessLockOwner } from './process-lock.ts';
14+
import {
15+
acquireProcessLock,
16+
withProcessLock,
17+
type ProcessLockOwner,
18+
type ProcessLockRelease,
19+
} from './process-lock.ts';
1520
import { readProcessStartTime } from './host-process.ts';
1621
import { mkdtempForTestSync } from './tmp-dir.fixtures.ts';
1722

18-
const RECLAIMED_MARK = '.reclaimed-';
19-
2023
let tmpDir: string;
2124

2225
beforeEach(() => {
@@ -293,13 +296,14 @@ test('one abandoned lock offered to two contenders is held by exactly one of the
293296
assert.ok(reason instanceof AppError);
294297
assert.equal(reason.details?.ownerLiveness, 'live');
295298
await (acquired[0] as PromiseFulfilledResult<() => Promise<void>>).value();
296-
assert.deepEqual(listReclaimedSiblings(tmpDir), []);
299+
assert.deepEqual(listReclaimSiblings(tmpDir), []);
297300
});
298301

299-
function listReclaimedSiblings(directory: string): string[] {
302+
/** A reclaim that finishes leaves neither a parked directory nor a mutex behind. */
303+
function listReclaimSiblings(directory: string): string[] {
300304
return fs
301305
.readdirSync(directory)
302-
.filter((entry) => entry.includes(RECLAIMED_MARK))
306+
.filter((entry) => entry.includes('.reclaim'))
303307
.sort();
304308
}
305309

@@ -374,28 +378,39 @@ test('acquireProcessLock reclaims a stray path in place of the lock directory',
374378
assert.equal(fs.existsSync(lockDirPath), false);
375379
});
376380

377-
test('a forced reclaim leaves a directory that a live owner republished', async () => {
378-
const lockDirPath = path.join(tmpDir, 'republished.lock');
381+
test('a contender that claims the path during a reclaim keeps its lock', async () => {
382+
const lockDirPath = path.join(tmpDir, 'claimed-during-reclaim.lock');
383+
const mutexPath = path.join(tmpDir, 'claimed-during-reclaim.reclaim.lock');
379384
const ownerFilePath = path.join(lockDirPath, 'owner.json');
380385
fs.mkdirSync(lockDirPath);
381386
fs.writeFileSync(
382387
ownerFilePath,
383388
JSON.stringify({ pid: 999_999_999, startTime: null, acquiredAtMs: Date.now() }),
384389
);
390+
stampDirectoryAbandoned(lockDirPath);
385391

386-
// A win32 handle refuses the rename, and by the time the forced removal would run a
387-
// live holder has claimed the path: the forced removal must not reach its directory.
388-
const realRename = fs.renameSync;
389-
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((
390-
from: fs.PathLike,
391-
to: fs.PathLike,
392+
// The moment a contender is admitted to judging this lock, another process clears the dead
393+
// claim and publishes itself. The judge must find a different directory, not a directory to
394+
// remove: nothing here stands between the two states, because nothing is moved out of the
395+
// way first.
396+
let claimed = false;
397+
const realMkdir = fs.mkdirSync;
398+
const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(((
399+
target: fs.PathLike,
400+
options?: fs.MakeDirectoryOptions & { recursive: true },
392401
) => {
393-
if (!String(to).includes(RECLAIMED_MARK)) return realRename(from, to);
394-
fs.rmSync(String(from), { recursive: true, force: true });
395-
fs.mkdirSync(String(from));
396-
fs.writeFileSync(path.join(String(from), 'owner.json'), JSON.stringify(currentProcessOwner()));
397-
throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
398-
}) as typeof fs.renameSync);
402+
if (String(target) !== mutexPath || claimed) {
403+
return realMkdir(target as string, options as fs.MakeDirectoryOptions);
404+
}
405+
claimed = true;
406+
fs.rmSync(lockDirPath, { recursive: true, force: true });
407+
fs.mkdirSync(lockDirPath);
408+
fs.writeFileSync(
409+
ownerFilePath,
410+
JSON.stringify({ ...currentProcessOwner(), claimToken: 'contender-claim' }),
411+
);
412+
return realMkdir(target as string, options as fs.MakeDirectoryOptions);
413+
}) as typeof fs.mkdirSync);
399414

400415
try {
401416
await assert.rejects(
@@ -409,48 +424,76 @@ test('a forced reclaim leaves a directory that a live owner republished', async
409424
(error: unknown) => {
410425
assert.ok(error instanceof AppError);
411426
assert.equal(error.details?.ownerLiveness, 'live');
427+
assert.equal(error.details?.ownerPid, process.pid);
412428
return true;
413429
},
414430
);
415-
assert.equal(fs.existsSync(ownerFilePath), true);
431+
assert.equal(claimed, true);
432+
const record = JSON.parse(fs.readFileSync(ownerFilePath, 'utf8')) as {
433+
pid: number;
434+
claimToken: string;
435+
};
436+
assert.equal(record.pid, process.pid);
437+
assert.equal(record.claimToken, 'contender-claim');
416438
} finally {
417-
renameSpy.mockRestore();
439+
mkdirSpy.mockRestore();
418440
}
419441
});
420442

421-
test('a reclaim that cannot remove the directory it moved aside still holds the lock', async () => {
422-
const lockDirPath = path.join(tmpDir, 'immovable.lock');
443+
test('a reclaim mutex another contender holds leaves the abandoned lock standing', async () => {
444+
const lockDirPath = path.join(tmpDir, 'judged-by-another.lock');
445+
const ownerFilePath = path.join(lockDirPath, 'owner.json');
446+
fs.mkdirSync(lockDirPath);
447+
const staleClaim = { pid: 999_999_999, startTime: null, acquiredAtMs: Date.now() };
448+
fs.writeFileSync(ownerFilePath, JSON.stringify(staleClaim));
449+
stampDirectoryAbandoned(lockDirPath);
450+
fs.mkdirSync(path.join(tmpDir, 'judged-by-another.reclaim.lock'));
451+
452+
// Nobody may judge this lock twice, and a contender that cannot say so keeps polling
453+
// rather than clearing what it has not finished reading.
454+
await assert.rejects(
455+
() =>
456+
acquireProcessLock({
457+
lockDirPath,
458+
owner: { pid: 999_999_998, startTime: null, acquiredAtMs: Date.now() },
459+
timeoutMs: 50,
460+
pollMs: 1,
461+
}),
462+
(error: unknown) => {
463+
assert.ok(error instanceof AppError);
464+
assert.equal(error.details?.ownerPid, 999_999_999);
465+
return true;
466+
},
467+
);
468+
assert.equal(fs.existsSync(ownerFilePath), true);
469+
});
470+
471+
test('a reclaim mutex left behind by a dead process is cleared by age', async () => {
472+
const lockDirPath = path.join(tmpDir, 'dead-janitor.lock');
473+
const mutexPath = path.join(tmpDir, 'dead-janitor.reclaim.lock');
423474
fs.mkdirSync(lockDirPath);
424475
fs.writeFileSync(
425476
path.join(lockDirPath, 'owner.json'),
426477
JSON.stringify({ pid: 999_999_999, startTime: null, acquiredAtMs: Date.now() }),
427478
);
479+
fs.mkdirSync(mutexPath);
428480
stampDirectoryAbandoned(lockDirPath);
481+
stampDirectoryAbandoned(mutexPath);
429482

430-
const realRemove = fs.rmSync;
431-
const removeSpy = vi.spyOn(fs, 'rmSync').mockImplementation(((target: fs.PathLike, options) => {
432-
if (String(target).includes(RECLAIMED_MARK)) {
433-
throw Object.assign(new Error('directory is busy'), { code: 'EBUSY' });
434-
}
435-
return realRemove(target, options);
436-
}) as typeof fs.rmSync);
483+
const release = await acquireProcessLock({
484+
lockDirPath,
485+
owner: currentProcessOwner(),
486+
timeoutMs: 500,
487+
pollMs: 1,
488+
});
437489

438-
try {
439-
const release = await acquireProcessLock({
440-
lockDirPath,
441-
owner: currentProcessOwner(),
442-
timeoutMs: 500,
443-
pollMs: 1,
444-
});
445-
assert.equal(fs.existsSync(path.join(lockDirPath, 'owner.json')), true);
446-
await release();
447-
} finally {
448-
removeSpy.mockRestore();
449-
}
490+
assert.equal(fs.existsSync(path.join(lockDirPath, 'owner.json')), true);
491+
assert.equal(fs.existsSync(mutexPath), false);
492+
await release();
450493
});
451494

452-
test('a live owner published between the stale read and the rename keeps its lock', async () => {
453-
const lockDirPath = path.join(tmpDir, 'stolen-race.lock');
495+
test('a reclaim that cannot clear the lock directory leaves the record it judged', async () => {
496+
const lockDirPath = path.join(tmpDir, 'immovable.lock');
454497
const ownerFilePath = path.join(lockDirPath, 'owner.json');
455498
fs.mkdirSync(lockDirPath);
456499
fs.writeFileSync(
@@ -459,93 +502,123 @@ test('a live owner published between the stale read and the rename keeps its loc
459502
);
460503
stampDirectoryAbandoned(lockDirPath);
461504

462-
// The dead record is read, and before the rename lands another contender reclaims the
463-
// path, publishes itself, and goes live. The rename then moves that live directory, and
464-
// the only thing that can tell it apart from the one judged abandoned is the directory
465-
// itself.
466-
let republished = false;
467-
const realRename = fs.renameSync;
468-
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((
469-
from: fs.PathLike,
470-
to: fs.PathLike,
471-
) => {
472-
if (!String(to).includes(RECLAIMED_MARK) || republished) return realRename(from, to);
473-
republished = true;
474-
fs.rmSync(String(from), { recursive: true, force: true });
475-
fs.mkdirSync(String(from));
476-
fs.writeFileSync(path.join(String(from), 'owner.json'), JSON.stringify(currentProcessOwner()));
477-
return realRename(from, to);
478-
}) as typeof fs.renameSync);
505+
const realRemove = fs.rmSync;
506+
const removeSpy = vi.spyOn(fs, 'rmSync').mockImplementation(((target: fs.PathLike) => {
507+
if (String(target) !== lockDirPath) {
508+
return realRemove(target as string);
509+
}
510+
throw Object.assign(new Error('directory is busy'), { code: 'EBUSY' });
511+
}) as typeof fs.rmSync);
479512

480513
try {
481514
await assert.rejects(
482515
() =>
483516
acquireProcessLock({
484517
lockDirPath,
485-
owner: { pid: 999_999_998, startTime: null, acquiredAtMs: Date.now() },
518+
owner: currentProcessOwner(),
486519
timeoutMs: 50,
487520
pollMs: 1,
488521
}),
489522
(error: unknown) => {
490523
assert.ok(error instanceof AppError);
491-
assert.equal(error.details?.ownerLiveness, 'live');
492-
assert.equal(error.details?.ownerPid, process.pid);
524+
assert.equal(error.details?.ownerPid, 999_999_999);
493525
return true;
494526
},
495527
);
496-
assert.equal(republished, true);
497-
assert.equal(
498-
(JSON.parse(fs.readFileSync(ownerFilePath, 'utf8')) as { pid: number }).pid,
499-
process.pid,
500-
);
501-
assert.deepEqual(
502-
fs
503-
.readdirSync(tmpDir)
504-
.filter((name) => name.includes(RECLAIMED_MARK))
505-
.sort(),
506-
[],
507-
);
528+
assert.equal(fs.existsSync(ownerFilePath), true);
508529
} finally {
509-
renameSpy.mockRestore();
530+
removeSpy.mockRestore();
510531
}
511532
});
512533

513-
test('a reclaim whose directory another contender moved aside retries and acquires', async () => {
514-
const lockDirPath = path.join(tmpDir, 'lost-race.lock');
534+
test('an abandoned lock with no record and something else inside is left alone', async () => {
535+
const lockDirPath = path.join(tmpDir, 'occupied.lock');
536+
const strangerPath = path.join(lockDirPath, 'not-a-record.json');
515537
fs.mkdirSync(lockDirPath);
516-
fs.writeFileSync(
517-
path.join(lockDirPath, 'owner.json'),
518-
JSON.stringify({ pid: 999_999_999, startTime: null, acquiredAtMs: Date.now() }),
519-
);
538+
fs.writeFileSync(strangerPath, 'nobody claims this');
520539
stampDirectoryAbandoned(lockDirPath);
540+
// Stamping the directory's own clocks back makes the stranger look older than the grace, too.
541+
fs.utimesSync(strangerPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
521542

522-
// The contender that loses the rename finds the path already gone and cannot have
523-
// cleared anything; it goes back to `mkdir`, which is what decides the lock.
524-
let attempted = 0;
525-
const realRename = fs.renameSync;
526-
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((
527-
from: fs.PathLike,
528-
to: fs.PathLike,
529-
) => {
530-
if (!String(to).includes(RECLAIMED_MARK)) return realRename(from, to);
531-
attempted += 1;
532-
fs.rmSync(lockDirPath, { recursive: true, force: true });
533-
throw Object.assign(new Error('no such directory'), { code: 'ENOENT' });
534-
}) as typeof fs.renameSync);
543+
// No record means no claim to attribute the directory to, and the age of the path is
544+
// evidence about the path alone. An empty directory is removed; this one is not.
545+
await assert.rejects(
546+
() =>
547+
acquireProcessLock({
548+
lockDirPath,
549+
owner: currentProcessOwner(),
550+
timeoutMs: 50,
551+
pollMs: 1,
552+
}),
553+
(error: unknown) => {
554+
assert.ok(error instanceof AppError);
555+
return true;
556+
},
557+
);
558+
assert.equal(fs.existsSync(strangerPath), true);
559+
});
535560

536-
try {
537-
const release = await acquireProcessLock({
538-
lockDirPath,
539-
owner: currentProcessOwner(),
540-
timeoutMs: 500,
541-
pollMs: 1,
542-
});
543-
assert.equal(attempted, 1);
544-
assert.equal(fs.existsSync(path.join(lockDirPath, 'owner.json')), true);
545-
await release();
546-
} finally {
547-
renameSpy.mockRestore();
548-
}
561+
test('withProcessLock gives the lock back on every path out of the task', async () => {
562+
const releases: string[] = [];
563+
const release: ProcessLockRelease = async () => {
564+
releases.push('released');
565+
};
566+
567+
await withProcessLock({
568+
acquire: async () => release,
569+
task: async () => 'done',
570+
});
571+
await assert.rejects(
572+
() =>
573+
withProcessLock({
574+
acquire: async () => release,
575+
task: async () => {
576+
throw new Error('task failed');
577+
},
578+
}),
579+
/task failed/,
580+
);
581+
582+
assert.deepEqual(releases, ['released', 'released']);
583+
});
584+
585+
test('a task that failed is reported over a release that could not verify ownership', async () => {
586+
await assert.rejects(
587+
() =>
588+
withProcessLock({
589+
acquire: async () => async () => {
590+
throw new AppError('COMMAND_FAILED', 'Cannot verify ownership of device claim', {
591+
ownerReleaseUnverified: true,
592+
});
593+
},
594+
task: async () => {
595+
throw new Error('the write was rejected');
596+
},
597+
}),
598+
(error: unknown) => {
599+
assert.equal((error as Error).message, 'the write was rejected');
600+
return true;
601+
},
602+
);
603+
});
604+
605+
test('a completed task still reports a lock it could not give back', async () => {
606+
await assert.rejects(
607+
() =>
608+
withProcessLock({
609+
acquire: async () => async () => {
610+
throw new AppError('COMMAND_FAILED', 'Cannot verify ownership of device claim', {
611+
ownerReleaseUnverified: true,
612+
});
613+
},
614+
task: async () => 'done',
615+
}),
616+
(error: unknown) => {
617+
assert.ok(error instanceof AppError);
618+
assert.equal(error.details?.ownerReleaseUnverified, true);
619+
return true;
620+
},
621+
);
549622
});
550623

551624
function stampDirectoryAbandoned(directory: string): void {

0 commit comments

Comments
 (0)