Skip to content

Commit 87c9364

Browse files
authored
Merge pull request #153 from Lykhoyda/fix/issue-111-atomic-writer-unique-tmp
fix(gh-111): unique .tmp suffixes + age-bounded cleanupOrphans
2 parents 38e7c9d + b6240e3 commit 87c9364

9 files changed

Lines changed: 324 additions & 32 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.44.39",
12+
"version": "0.44.40",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.44.39",
3+
"version": "0.44.40",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ All notable changes to rn-dev-agent will be documented in this file.
44

55
Format follows [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.44.40] — 2026-05-13
8+
9+
### Hardened (GH #111 — atomic-writer concurrent pairWrite races)
10+
11+
- **`pairWrite` now uses unique `.tmp.<pid>.<time36>.<rand36>` suffixes** so
12+
two concurrent writers against the same action don't share a tmp namespace.
13+
Without this, `cleanupOrphans` could `unlinkSync` writer A's in-flight tmp
14+
file mid-rename, producing an opaque ENOENT for the user. Gemini flagged
15+
the failure mode at conf 82 in the PR #109 multi-LLM review.
16+
- **`cleanupOrphans` is age-bounded**: scans the target directory for files
17+
matching the path prefix and only unlinks orphans older than
18+
`ORPHAN_MAX_AGE_MS = 5 minutes`. Concurrent writer's fresh tmp file
19+
preserved; crashed process's stale tmp file becomes eligible for sweep
20+
after 5 minutes.
21+
- **New `_readdir` test seam** for deterministic cleanup mocking.
22+
- 7 new regression tests cover unique-stamp generation, stale-orphan sweep,
23+
fresh-orphan preservation, prefix anchoring, missing-dir tolerance, the
24+
exported constant value, and round-trip orphan-free state across 5
25+
repeated `pairWrite` calls. Three existing tests updated to match the
26+
new `.tmp.<stamp>` filename shape. Suite: 1312 → 1319 passing.
27+
728
## [0.44.39] — 2026-05-13
829

930
### Hardened (GH #110 — agent-device test seam fuse)

scripts/cdp-bridge/dist/domain/atomic-writer.js

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434
//
3535
// Test seam: the public API is on a single exported object so tests can
3636
// `mock.method(atomicWriter, '_writeFile', ...)` to inject failures.
37-
import { writeFileSync, renameSync, statSync, mkdirSync, existsSync, unlinkSync, } from 'node:fs';
38-
import { dirname } from 'node:path';
37+
import { writeFileSync, renameSync, statSync, mkdirSync, existsSync, unlinkSync, readdirSync, } from 'node:fs';
38+
import { dirname, basename } from 'node:path';
3939
// Multi-LLM review of PR #109 findings 1+2: `finalMtimeMs = _stat(yaml)`
4040
// breaks the safety invariant in two scenarios — (a) slow writes where
4141
// the actual YAML mtime exceeds `projectedMtimeMs` and step 5 happens
@@ -56,6 +56,23 @@ import { dirname } from 'node:path';
5656
* 1 second satisfies both with a safe margin.
5757
*/
5858
export const FUTURE_MTIME_BUFFER_MS = 1_000;
59+
/**
60+
* GH #111: orphan .tmp files older than this threshold are eligible for
61+
* cleanup. Any process that's been alive for 5 minutes hasn't crashed
62+
* mid-pairWrite — the orphan must be from a prior crashed run. Concurrent
63+
* pairWrite calls don't collide because each call uses a unique stamp,
64+
* but a stale orphan from a crashed process would otherwise stick around
65+
* forever. 5 minutes is conservative (allows long fsync queues / CI
66+
* antivirus stalls).
67+
*/
68+
export const ORPHAN_MAX_AGE_MS = 5 * 60 * 1_000;
69+
/** Generate a unique tmp-file stamp per pairWrite call. Crash-resistant
70+
* and concurrent-safe — two pairWrites for the same action path won't
71+
* collide because each owns its own tmp namespace. */
72+
function generateTmpStamp() {
73+
const rand = Math.random().toString(36).slice(2, 10);
74+
return `${process.pid}.${Date.now().toString(36)}.${rand}`;
75+
}
5976
/**
6077
* Atomic write of a (YAML, sidecar) pair using sidecar-first ordering.
6178
* Returns the resolved paths plus the final on-disk mtime. Throws if any
@@ -76,8 +93,13 @@ export const FUTURE_MTIME_BUFFER_MS = 1_000;
7693
function pairWriteImpl(yamlPath, yamlContent, sidecarPath, state) {
7794
ensureDir(yamlPath);
7895
ensureDir(sidecarPath);
79-
const yamlTmp = `${yamlPath}.tmp`;
80-
const sidecarTmp = `${sidecarPath}.tmp`;
96+
// GH #111: unique stamp per call so two concurrent pairWrites against
97+
// the same action id never share a tmp namespace. Without this, B's
98+
// cleanupOrphans could unlink A's in-flight .tmp file and produce an
99+
// opaque ENOENT during A's rename.
100+
const stamp = generateTmpStamp();
101+
const yamlTmp = `${yamlPath}.tmp.${stamp}`;
102+
const sidecarTmp = `${sidecarPath}.tmp.${stamp}`;
81103
// Step 1+2: sidecar with projected future mtime, atomic rename.
82104
const projectedMtimeMs = Date.now() + FUTURE_MTIME_BUFFER_MS;
83105
const projectedState = {
@@ -123,21 +145,41 @@ function ensureDir(filePath) {
123145
atomicWriter._mkdir(dir);
124146
}
125147
/**
126-
* Best-effort cleanup of orphaned `.tmp` files left by a crashed previous
127-
* call. Called by `pairWrite` before each operation. Idempotent.
148+
* Best-effort cleanup of orphaned `.tmp.<stamp>` files left by a crashed
149+
* previous call (GH #111). Called by `pairWrite` before each operation.
150+
* Idempotent.
151+
*
152+
* Only removes `.tmp.<stamp>` files matching the target path's prefix
153+
* AND older than ORPHAN_MAX_AGE_MS — concurrent writers' fresh tmp files
154+
* are untouched. A crashed process's stale tmp file becomes eligible
155+
* after 5 minutes, well past any plausible pairWrite duration.
128156
*
129-
* Routes through `atomicWriter._unlink` / `_exists` so PR #109 review
130-
* finding (E) — "tests can't simulate mkdir/unlink failures" — is
131-
* resolved: the seam is now complete across all fs operations the
132-
* writer performs.
157+
* Routes through `atomicWriter._readdir` / `_statMtimeMs` / `_unlink`
158+
* so tests can mock cleanup behavior deterministically.
133159
*/
134160
function cleanupOrphans(yamlPath, sidecarPath) {
135-
for (const orphan of [`${yamlPath}.tmp`, `${sidecarPath}.tmp`]) {
136-
if (atomicWriter._exists(orphan)) {
161+
const now = Date.now();
162+
for (const targetPath of [yamlPath, sidecarPath]) {
163+
const dir = dirname(targetPath);
164+
const prefix = `${basename(targetPath)}.tmp.`;
165+
let entries;
166+
try {
167+
entries = atomicWriter._readdir(dir);
168+
}
169+
catch {
170+
continue; // dir doesn't exist yet — no orphans possible
171+
}
172+
for (const entry of entries) {
173+
if (!entry.startsWith(prefix))
174+
continue;
175+
const orphanPath = `${dir}/${entry}`;
137176
try {
138-
atomicWriter._unlink(orphan);
177+
const mtimeMs = atomicWriter._statMtimeMs(orphanPath);
178+
if (now - mtimeMs < ORPHAN_MAX_AGE_MS)
179+
continue; // fresh — likely a concurrent writer's
180+
atomicWriter._unlink(orphanPath);
139181
}
140-
catch { /* ignore */ }
182+
catch { /* best-effort */ }
141183
}
142184
}
143185
}
@@ -172,6 +214,10 @@ export const atomicWriter = {
172214
_unlink(path) {
173215
unlinkSync(path);
174216
},
217+
/** Underlying `fs.readdirSync(path)`. Used by GH #111 prefix-scan cleanup. */
218+
_readdir(path) {
219+
return readdirSync(path);
220+
},
175221
/**
176222
* Atomic pair-write. Cleans up any orphaned `.tmp` files before
177223
* starting. Throws on the first failed step — caller decides whether

scripts/cdp-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent-cdp",
3-
"version": "0.38.34",
3+
"version": "0.38.35",
44
"type": "module",
55
"main": "dist/index.js",
66
"scripts": {

scripts/cdp-bridge/src/domain/atomic-writer.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ import {
4242
mkdirSync,
4343
existsSync,
4444
unlinkSync,
45+
readdirSync,
4546
} from 'node:fs';
46-
import { dirname } from 'node:path';
47+
import { dirname, basename } from 'node:path';
4748
import type { ActionRuntimeState } from './reusable-action.js';
4849

4950
// Multi-LLM review of PR #109 findings 1+2: `finalMtimeMs = _stat(yaml)`
@@ -68,6 +69,25 @@ import type { ActionRuntimeState } from './reusable-action.js';
6869
*/
6970
export const FUTURE_MTIME_BUFFER_MS = 1_000;
7071

72+
/**
73+
* GH #111: orphan .tmp files older than this threshold are eligible for
74+
* cleanup. Any process that's been alive for 5 minutes hasn't crashed
75+
* mid-pairWrite — the orphan must be from a prior crashed run. Concurrent
76+
* pairWrite calls don't collide because each call uses a unique stamp,
77+
* but a stale orphan from a crashed process would otherwise stick around
78+
* forever. 5 minutes is conservative (allows long fsync queues / CI
79+
* antivirus stalls).
80+
*/
81+
export const ORPHAN_MAX_AGE_MS = 5 * 60 * 1_000;
82+
83+
/** Generate a unique tmp-file stamp per pairWrite call. Crash-resistant
84+
* and concurrent-safe — two pairWrites for the same action path won't
85+
* collide because each owns its own tmp namespace. */
86+
function generateTmpStamp(): string {
87+
const rand = Math.random().toString(36).slice(2, 10);
88+
return `${process.pid}.${Date.now().toString(36)}.${rand}`;
89+
}
90+
7191
export interface PairWriteResult {
7292
yamlPath: string;
7393
sidecarPath: string;
@@ -103,8 +123,13 @@ function pairWriteImpl(
103123
ensureDir(yamlPath);
104124
ensureDir(sidecarPath);
105125

106-
const yamlTmp = `${yamlPath}.tmp`;
107-
const sidecarTmp = `${sidecarPath}.tmp`;
126+
// GH #111: unique stamp per call so two concurrent pairWrites against
127+
// the same action id never share a tmp namespace. Without this, B's
128+
// cleanupOrphans could unlink A's in-flight .tmp file and produce an
129+
// opaque ENOENT during A's rename.
130+
const stamp = generateTmpStamp();
131+
const yamlTmp = `${yamlPath}.tmp.${stamp}`;
132+
const sidecarTmp = `${sidecarPath}.tmp.${stamp}`;
108133

109134
// Step 1+2: sidecar with projected future mtime, atomic rename.
110135
const projectedMtimeMs = Date.now() + FUTURE_MTIME_BUFFER_MS;
@@ -155,18 +180,37 @@ function ensureDir(filePath: string): void {
155180
}
156181

157182
/**
158-
* Best-effort cleanup of orphaned `.tmp` files left by a crashed previous
159-
* call. Called by `pairWrite` before each operation. Idempotent.
183+
* Best-effort cleanup of orphaned `.tmp.<stamp>` files left by a crashed
184+
* previous call (GH #111). Called by `pairWrite` before each operation.
185+
* Idempotent.
186+
*
187+
* Only removes `.tmp.<stamp>` files matching the target path's prefix
188+
* AND older than ORPHAN_MAX_AGE_MS — concurrent writers' fresh tmp files
189+
* are untouched. A crashed process's stale tmp file becomes eligible
190+
* after 5 minutes, well past any plausible pairWrite duration.
160191
*
161-
* Routes through `atomicWriter._unlink` / `_exists` so PR #109 review
162-
* finding (E) — "tests can't simulate mkdir/unlink failures" — is
163-
* resolved: the seam is now complete across all fs operations the
164-
* writer performs.
192+
* Routes through `atomicWriter._readdir` / `_statMtimeMs` / `_unlink`
193+
* so tests can mock cleanup behavior deterministically.
165194
*/
166195
function cleanupOrphans(yamlPath: string, sidecarPath: string): void {
167-
for (const orphan of [`${yamlPath}.tmp`, `${sidecarPath}.tmp`]) {
168-
if (atomicWriter._exists(orphan)) {
169-
try { atomicWriter._unlink(orphan); } catch { /* ignore */ }
196+
const now = Date.now();
197+
for (const targetPath of [yamlPath, sidecarPath]) {
198+
const dir = dirname(targetPath);
199+
const prefix = `${basename(targetPath)}.tmp.`;
200+
let entries: string[];
201+
try {
202+
entries = atomicWriter._readdir(dir);
203+
} catch {
204+
continue; // dir doesn't exist yet — no orphans possible
205+
}
206+
for (const entry of entries) {
207+
if (!entry.startsWith(prefix)) continue;
208+
const orphanPath = `${dir}/${entry}`;
209+
try {
210+
const mtimeMs = atomicWriter._statMtimeMs(orphanPath);
211+
if (now - mtimeMs < ORPHAN_MAX_AGE_MS) continue; // fresh — likely a concurrent writer's
212+
atomicWriter._unlink(orphanPath);
213+
} catch { /* best-effort */ }
170214
}
171215
}
172216
}
@@ -202,6 +246,10 @@ export const atomicWriter = {
202246
_unlink(path: string): void {
203247
unlinkSync(path);
204248
},
249+
/** Underlying `fs.readdirSync(path)`. Used by GH #111 prefix-scan cleanup. */
250+
_readdir(path: string): string[] {
251+
return readdirSync(path);
252+
},
205253

206254
/**
207255
* Atomic pair-write. Cleans up any orphaned `.tmp` files before

0 commit comments

Comments
 (0)