Skip to content

Commit 3c3a2e0

Browse files
Recovered: fix(bundle): atomic re-commit via per-entry aside (#196 by @SahilRakhaiya05) (#246)
* fix(bundle): atomic re-commit via per-entry aside with rollback * fix(bundle): move meta.json aside before other bundle entries during commit --------- Co-authored-by: SahilRakhaiya05 <sahil.rakhaiya117814@marwadiuniversity.ac.in>
1 parent 3abcf2d commit 3c3a2e0

2 files changed

Lines changed: 212 additions & 72 deletions

File tree

src/lib/bundle.commit.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type * as NodeFsPromises from 'node:fs/promises';
2+
import {
3+
existsSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readdirSync,
7+
readFileSync,
8+
writeFileSync,
9+
} from 'node:fs';
10+
import { tmpdir } from 'node:os';
11+
import { join } from 'node:path';
12+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13+
14+
const renameMock = vi.hoisted(() => vi.fn());
15+
const rmMock = vi.hoisted(() => vi.fn());
16+
17+
vi.mock('node:fs/promises', async importOriginal => {
18+
const actual = (await importOriginal()) as typeof NodeFsPromises;
19+
return {
20+
...actual,
21+
rename: renameMock,
22+
rm: rmMock,
23+
};
24+
});
25+
26+
const { commitBundle } = await import('./bundle.js');
27+
28+
describe('commitBundle', () => {
29+
let realRename: typeof NodeFsPromises.rename;
30+
let realRm: typeof NodeFsPromises.rm;
31+
32+
beforeEach(async () => {
33+
const actual = (await vi.importActual('node:fs/promises')) as typeof NodeFsPromises;
34+
realRename = actual.rename;
35+
realRm = actual.rm;
36+
renameMock.mockImplementation(realRename);
37+
rmMock.mockImplementation(realRm);
38+
});
39+
40+
afterEach(() => {
41+
renameMock.mockReset();
42+
rmMock.mockReset();
43+
});
44+
45+
async function withTempParent(run: (parent: string) => Promise<void>): Promise<void> {
46+
const parent = mkdtempSync(join(tmpdir(), 'bundle-commit-parent-'));
47+
try {
48+
await run(parent);
49+
} finally {
50+
await realRm(parent, { recursive: true, force: true }).catch(() => undefined);
51+
}
52+
}
53+
54+
function seedBundleDirs(parent: string): { dir: string; tmpDir: string; files: string[] } {
55+
const dir = join(parent, 'bundle');
56+
const tmpDir = join(dir, '.tmp');
57+
58+
mkdirSync(dir, { recursive: true });
59+
writeFileSync(join(dir, 'notes.txt'), 'foreign notes\n', 'utf8');
60+
mkdirSync(join(dir, 'steps'), { recursive: true });
61+
writeFileSync(join(dir, 'meta.json'), '{"snapshotId":"snap_old"}\n', 'utf8');
62+
writeFileSync(join(dir, 'steps', '01-evidence.json'), '{"step":1}\n', 'utf8');
63+
64+
mkdirSync(join(tmpDir, 'steps'), { recursive: true });
65+
writeFileSync(join(tmpDir, 'meta.json'), '{"snapshotId":"snap_new"}\n', 'utf8');
66+
writeFileSync(join(tmpDir, 'result.json'), '{}\n', 'utf8');
67+
writeFileSync(join(tmpDir, 'steps', '01-evidence.json'), '{"step":9}\n', 'utf8');
68+
69+
return { dir, tmpDir, files: ['result.json', 'meta.json', 'steps/01-evidence.json'] };
70+
}
71+
72+
it('rolls back to the prior complete bundle when a staged rename fails', async () => {
73+
await withTempParent(async parent => {
74+
const { dir, tmpDir, files } = seedBundleDirs(parent);
75+
76+
renameMock.mockImplementation(async (oldPath, newPath) => {
77+
const dest = String(newPath);
78+
if (dest.endsWith('result.json') && !dest.includes('.aside.')) {
79+
throw Object.assign(new Error('simulated install failure'), { code: 'EACCES' });
80+
}
81+
return realRename(oldPath, newPath);
82+
});
83+
84+
await expect(commitBundle(tmpDir, dir, files)).rejects.toThrow('simulated install failure');
85+
86+
expect(readFileSync(join(dir, 'meta.json'), 'utf8')).toBe('{"snapshotId":"snap_old"}\n');
87+
expect(readFileSync(join(dir, 'steps', '01-evidence.json'), 'utf8')).toBe('{"step":1}\n');
88+
expect(readFileSync(join(dir, 'notes.txt'), 'utf8')).toBe('foreign notes\n');
89+
const leftovers = readdirSync(parent).filter(name => name.includes('.aside.'));
90+
expect(leftovers).toEqual([]);
91+
});
92+
});
93+
94+
it('preserves foreign files while installing the new bundle on success', async () => {
95+
await withTempParent(async parent => {
96+
const { dir, tmpDir, files } = seedBundleDirs(parent);
97+
98+
await expect(commitBundle(tmpDir, dir, files)).resolves.toBeUndefined();
99+
100+
expect(readFileSync(join(dir, 'meta.json'), 'utf8')).toBe('{"snapshotId":"snap_new"}\n');
101+
expect(readFileSync(join(dir, 'steps', '01-evidence.json'), 'utf8')).toBe('{"step":9}\n');
102+
expect(readFileSync(join(dir, 'notes.txt'), 'utf8')).toBe('foreign notes\n');
103+
expect(existsSync(join(dir, 'result.json'))).toBe(true);
104+
});
105+
});
106+
107+
it('keeps the new bundle when post-commit aside cleanup fails', async () => {
108+
await withTempParent(async parent => {
109+
const { dir, tmpDir, files } = seedBundleDirs(parent);
110+
111+
rmMock.mockImplementation(async (path, options) => {
112+
if (String(path).includes('.aside.')) {
113+
throw Object.assign(new Error('simulated aside cleanup failure'), { code: 'EACCES' });
114+
}
115+
return realRm(path, options);
116+
});
117+
118+
await expect(commitBundle(tmpDir, dir, files)).resolves.toBeUndefined();
119+
120+
expect(readFileSync(join(dir, 'meta.json'), 'utf8')).toBe('{"snapshotId":"snap_new"}\n');
121+
expect(readFileSync(join(dir, 'notes.txt'), 'utf8')).toBe('foreign notes\n');
122+
});
123+
});
124+
});

src/lib/bundle.ts

Lines changed: 88 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@
3333
* when the agent re-runs. M3 may add resume.
3434
*/
3535

36+
import { randomUUID } from 'node:crypto';
3637
import { mkdir, mkdtemp, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3738
import type { Writable } from 'node:stream';
3839
import { pipeline } from 'node:stream/promises';
3940
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
4041
import { createWriteStream } from 'node:fs';
41-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
42+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4243
import type { CliFailureContext, CliTestStep } from '../commands/test.js';
4344
import { ApiError, TransportError, localValidationError } from './errors.js';
4445
import { requireEnum } from './validate.js';
@@ -536,30 +537,6 @@ async function freshTmpDir(dir: string): Promise<string> {
536537
return tmpDir;
537538
}
538539

539-
/**
540-
* Rename `<tmp>/<file>` → `<dir>/<file>` for every file in `files`.
541-
*
542-
* Critical ordering for atomicity (the §3 "agent-safe" contract):
543-
*
544-
* 1. **Remove the OLD `meta.json` first.** The bundle's completion
545-
* signal is `meta.json`'s presence; an agent reading `<dir>` while
546-
* we're mutating it must see "no meta → bundle absent or
547-
* mid-write" rather than "meta points at a snapshot that's already
548-
* been partially overwritten." Removing the old meta is what
549-
* makes the rest of the swap safe to do in place.
550-
* 2. Wipe stale top-level files (e.g. an old `video.mp4` when the new
551-
* bundle has no video). Without this, a fresh bundle could ship
552-
* with a stale video lingering at the top level.
553-
* 3. Replace `<dir>/steps/` wholesale.
554-
* 4. Rename top-level files into place.
555-
* 5. **Rename `meta.json` LAST.** Its visible presence is the atomic
556-
* completion signal; until step 5 lands, agents see "incomplete."
557-
*
558-
* The window between (1) and (5) is bounded by a handful of `rename`
559-
* syscalls — small enough that a SIGKILL there is rare, and any agent
560-
* caught reading the dir during it sees no meta and refuses to consume
561-
* (per §7.3). That's what we want.
562-
*/
563540
/**
564541
* Whether a top-level directory entry belongs to the bundle format —
565542
* i.e. something a prior `writeBundle` could have produced and this
@@ -584,62 +561,101 @@ export function isBundleOwnedEntry(entry: string): boolean {
584561
return /^code\.[A-Za-z0-9]+$/.test(entry);
585562
}
586563

587-
async function commitBundle(
564+
/**
565+
* Atomically install the complete bundle staged in `tmpDir` into `dir`.
566+
*
567+
* Compatible with the #162 data-loss guard: only `isBundleOwnedEntry`
568+
* names are moved aside or replaced — foreign files in `--out` survive.
569+
*
570+
* Re-commit safety: bundle-owned entries are renamed to a sibling
571+
* aside directory before the new artifacts land. The prior `meta.json`
572+
* is moved aside first (§3/§7.3 — no meta ⇒ refuse to consume) so a
573+
* concurrent reader never sees meta pointing at steps already gone.
574+
* The new `meta.json` is installed last. On failure, aside entries are
575+
* restored so a failed re-commit never leaves the directory unusable.
576+
*/
577+
export async function commitBundle(
588578
tmpDir: string,
589579
dir: string,
590580
files: ReadonlyArray<string>,
591581
): Promise<void> {
592-
// (1) Remove the prior bundle's completion signal FIRST.
593-
await unlink(join(dir, 'meta.json')).catch(() => undefined);
594-
595-
// (2) Sweep stale top-level files that the new bundle won't write.
596-
// If the prior run wrote `video.mp4` and the new run has no video,
597-
// an in-place rename leaves the old video lingering. Only entries the
598-
// bundle format OWNS are candidates: `--out` may point at a directory
599-
// that also holds the user's unrelated files, and those must survive
600-
// the commit (deleting them would be silent data loss).
601-
const topLevel = files.filter(f => !f.startsWith('steps/'));
602-
const newTopLevelSet = new Set(topLevel);
603-
newTopLevelSet.add('meta.json'); // about to land last, do not delete
604-
const existing = await readdir(dir).catch(() => [] as string[]);
605-
for (const entry of existing) {
606-
// Preserve the writer's own scratch dir + the .partial marker
607-
// (we'll re-evaluate .partial at the end of commit). Any other
608-
// bundle-owned entry not-listed in the new bundle is stale.
609-
if (entry === '.tmp' || entry === '.partial') continue;
610-
if (newTopLevelSet.has(entry)) continue;
611-
if (entry === 'steps') continue; // handled below
612-
if (!isBundleOwnedEntry(entry)) continue; // foreign file — never touch
613-
await rm(join(dir, entry), { recursive: true, force: true });
614-
}
582+
const parent = dirname(dir);
583+
const base = basename(dir);
584+
const asideDir = join(parent, `.${base}.aside.${randomUUID()}`);
585+
const asideLog: Array<{ asidePath: string; restorePath: string }> = [];
586+
587+
const asideIfPresent = async (entry: string): Promise<void> => {
588+
const restorePath = join(dir, entry);
589+
if (!(await pathExists(restorePath))) return;
590+
const asidePath = join(asideDir, entry);
591+
await mkdir(dirname(asidePath), { recursive: true });
592+
await rename(restorePath, asidePath);
593+
asideLog.push({ asidePath, restorePath });
594+
};
615595

616-
// (3) Replace `<dir>/steps/` with `<tmp>/steps/`.
617-
const stepsTmp = join(tmpDir, 'steps');
618-
const stepsDir = join(dir, 'steps');
619-
await rm(stepsDir, { recursive: true, force: true });
620-
if (await dirExists(stepsTmp)) {
621-
await rename(stepsTmp, stepsDir);
622-
}
596+
const rollback = async (): Promise<void> => {
597+
for (const { asidePath, restorePath } of [...asideLog].reverse()) {
598+
await rm(restorePath, { recursive: true, force: true }).catch(() => undefined);
599+
await rename(asidePath, restorePath).catch(() => undefined);
600+
}
601+
await rm(asideDir, { recursive: true, force: true }).catch(() => undefined);
602+
};
623603

624-
// (4) Top-level files (result/failure/code/video). meta.json renames
625-
// LAST; track it separately.
626-
const metaIdx = topLevel.indexOf('meta.json');
627-
const beforeMeta = metaIdx >= 0 ? topLevel.filter((_, i) => i !== metaIdx) : topLevel;
628-
for (const file of beforeMeta) {
629-
await rename(join(tmpDir, file), join(dir, file));
630-
}
604+
try {
605+
const topLevel = files.filter(f => !f.startsWith('steps/'));
606+
const newTopLevelSet = new Set(topLevel);
607+
newTopLevelSet.add('meta.json');
608+
609+
// meta.json first — its presence is the completion signal; do not
610+
// move steps (or anything else) aside while a stale meta is still visible.
611+
await asideIfPresent('meta.json');
612+
613+
const existing = await readdir(dir).catch(() => [] as string[]);
614+
for (const entry of existing) {
615+
if (entry === '.tmp' || entry === 'meta.json') continue;
616+
if (!isBundleOwnedEntry(entry)) continue;
617+
618+
const isStale = entry !== 'steps' && entry !== '.partial' && !newTopLevelSet.has(entry);
619+
const willReplace = entry === 'steps' || newTopLevelSet.has(entry);
620+
if (isStale || willReplace) {
621+
await asideIfPresent(entry);
622+
}
623+
}
631624

632-
// (5) meta.json LAST → atomic completion signal.
633-
if (metaIdx >= 0) {
634-
await rename(join(tmpDir, 'meta.json'), join(dir, 'meta.json'));
635-
}
625+
const stepsTmp = join(tmpDir, 'steps');
626+
const stepsDir = join(dir, 'steps');
627+
if (await dirExists(stepsTmp)) {
628+
await rename(stepsTmp, stepsDir);
629+
}
636630

637-
// .partial from a prior aborted run is now stale. Remove it so an
638-
// agent inspecting the dir sees only the fresh bundle.
639-
await unlink(join(dir, '.partial')).catch(() => undefined);
631+
const metaIdx = topLevel.indexOf('meta.json');
632+
const beforeMeta = metaIdx >= 0 ? topLevel.filter((_, i) => i !== metaIdx) : topLevel;
633+
for (const file of beforeMeta) {
634+
await rename(join(tmpDir, file), join(dir, file));
635+
}
640636

641-
// Clean up the now-empty tmp dir.
642-
await rm(tmpDir, { recursive: true, force: true });
637+
if (metaIdx >= 0) {
638+
await rename(join(tmpDir, 'meta.json'), join(dir, 'meta.json'));
639+
}
640+
641+
await unlink(join(dir, '.partial')).catch(() => undefined);
642+
await rm(tmpDir, { recursive: true, force: true });
643+
// Best-effort aside cleanup — failures must not roll back a committed bundle.
644+
await rm(asideDir, { recursive: true, force: true }).catch(() => undefined);
645+
} catch (err) {
646+
await rollback();
647+
await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
648+
throw err;
649+
}
650+
}
651+
652+
async function pathExists(path: string): Promise<boolean> {
653+
try {
654+
await stat(path);
655+
return true;
656+
} catch {
657+
return false;
658+
}
643659
}
644660

645661
async function dirExists(path: string): Promise<boolean> {

0 commit comments

Comments
 (0)