Skip to content

Commit 0d9d53f

Browse files
committed
fix(senpi,processing): revalidate trust lease before state deletion; restore tail dual gate and schedule coverage
1 parent e3e1569 commit 0d9d53f

5 files changed

Lines changed: 259 additions & 87 deletions

File tree

src/processing/tail.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ export interface RawTranscriptSessionWatchOptions extends RawTranscriptSessionTa
265265
export interface RawTranscriptSessionCommitOptions {
266266
readonly markerDir?: string;
267267
readonly allowedMarkerRoots?: readonly string[];
268+
readonly lock?: RawTranscriptSessionLockOptions;
269+
readonly onLocked?: () => void | Promise<void>;
268270
}
269271

270272
export interface RawTranscriptReadOptions {
@@ -612,7 +614,11 @@ export async function commitRawTranscriptSessionCheckpoint(
612614
markerPath,
613615
sessionId,
614616
mainPathDigest,
615-
checkpoint
617+
checkpoint,
618+
{
619+
...(options.lock === undefined ? {} : { lock: options.lock }),
620+
...(options.onLocked === undefined ? {} : { onLocked: options.onLocked }),
621+
}
616622
);
617623
}
618624

@@ -887,7 +893,10 @@ async function mutateRawTranscriptSessionMarker(
887893
sessionId: string,
888894
mainPathDigest: string,
889895
checkpoint: RawTranscriptSessionCheckpoint,
890-
hooks?: RawTranscriptSessionLockHooks
896+
options: {
897+
readonly lock?: RawTranscriptSessionLockOptions;
898+
readonly onLocked?: () => void | Promise<void>;
899+
} = {}
891900
): Promise<void> {
892901
await withRawTranscriptSessionMarkerLock(
893902
markerPath,
@@ -914,17 +923,19 @@ async function mutateRawTranscriptSessionMarker(
914923
currentRevision + 1,
915924
nextMarkers
916925
);
926+
await options.onLocked?.();
917927
},
918-
hooks
928+
options.lock
919929
);
920930
}
921931

922932
/**
923933
* Hold the raw-transcript session marker lock around `action`.
924934
*
925935
* Uses the shared token-lease core on `<markerPath>.lock`. Age-expired
926-
* tokens are reclaimable only when the recorded pid is dead and
927-
* `createdAt` is within clock-skew. `LeaseLockBusyError` is retried
936+
* tokens are reclaimable only when the recorded pid is dead,
937+
* `createdAt` is within clock-skew, and
938+
* `now - createdAt >= SESSION_MARKER_LOCK_STALE_MS`. `LeaseLockBusyError` is retried
928939
* until `acquireTimeoutMs`. The canonical lock directory is never renamed or recursively removed; live tokens are never unlinked by another owner.
929940
*
930941
* @param markerPath - Session marker file whose sibling `.lock` is held.
@@ -1017,11 +1028,14 @@ function canReclaimRawTranscriptExpiredToken(
10171028
): boolean {
10181029
const { token } = captured;
10191030
if (!('pid' in token)) return false;
1020-
if (
1021-
'createdAt' in token &&
1022-
!isValidRawTranscriptLockCreatedAt(token.createdAt, now())
1023-
) {
1024-
return false;
1031+
if ('createdAt' in token) {
1032+
const createdAt = token.createdAt;
1033+
if (!isValidRawTranscriptLockCreatedAt(createdAt, now())) {
1034+
return false;
1035+
}
1036+
if (now() - createdAt < SESSION_MARKER_LOCK_STALE_MS) {
1037+
return false;
1038+
}
10251039
}
10261040
return !probeAlive(token.pid);
10271041
}

src/senpi/trust-writer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export async function removeSenpiHookTrustEntry(
346346
key => key !== 'version' && key !== 'hooks'
347347
);
348348
if (leftoverIds.length === 0 && leftoverRootKeys.length === 0) {
349+
await lease.assertHeld();
349350
rmSync(statePath, { force: true });
350351
return { path: statePath, id, removed: existed };
351352
}

tests/raw-transcript-lock-schedules.test.ts

Lines changed: 112 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { randomUUID } from 'node:crypto';
1+
import { createHash, randomUUID } from 'node:crypto';
22
import {
33
mkdir,
44
mkdtemp,
@@ -9,14 +9,17 @@ import {
99
writeFile,
1010
} from 'node:fs/promises';
1111
import { tmpdir } from 'node:os';
12-
import { join } from 'node:path';
12+
import { join, resolve } from 'node:path';
1313

1414
import { afterEach, describe, expect, it } from 'vitest';
1515

1616
import {
17-
withRawTranscriptSessionMarkerLock,
17+
commitRawTranscriptSessionCheckpoint,
18+
getRawTranscriptSessionMarkerPath,
19+
type RawTranscriptSessionCommitOptions,
1820
type RawTranscriptSessionLockOptions,
1921
} from '../src/processing/tail.js';
22+
import type { RawTranscriptSessionCheckpoint } from '../src/processing/types.js';
2023

2124
const tempRoots: string[] = [];
2225
const STALE_MS = 30_000;
@@ -42,13 +45,72 @@ afterEach(async () => {
4245

4346
async function fixture(label: string): Promise<{
4447
root: string;
48+
mainPath: string;
4549
markerPath: string;
4650
lockPath: string;
4751
}> {
4852
const root = await mkdtemp(join(tmpdir(), `raw-transcript-lock-${label}-`));
4953
tempRoots.push(root);
50-
const markerPath = join(root, 'session.marker.json');
51-
return { root, markerPath, lockPath: `${markerPath}.lock` };
54+
const mainPath = join(root, 'session.jsonl');
55+
const markerDir = join(root, 'markers');
56+
await mkdir(markerDir, { recursive: true, mode: 0o700 });
57+
await writeFile(mainPath, '');
58+
const markerPath = getRawTranscriptSessionMarkerPath(mainPath, markerDir, [
59+
root,
60+
]);
61+
return { root, mainPath, markerPath, lockPath: `${markerPath}.lock` };
62+
}
63+
64+
function commitOptions(
65+
root: string,
66+
extras: Omit<
67+
RawTranscriptSessionCommitOptions,
68+
'markerDir' | 'allowedMarkerRoots'
69+
> = {}
70+
): RawTranscriptSessionCommitOptions {
71+
return {
72+
markerDir: join(root, 'markers'),
73+
allowedMarkerRoots: [root],
74+
...extras,
75+
};
76+
}
77+
78+
function checkpoint(
79+
mainPath: string,
80+
baseRevision: number
81+
): RawTranscriptSessionCheckpoint {
82+
return {
83+
sessionId: 'session',
84+
mainPathDigest: createHash('sha256')
85+
.update(resolve(mainPath))
86+
.digest('hex'),
87+
baseRevision,
88+
sources: [
89+
{
90+
sourceKind: 'main',
91+
sourceId: 'main',
92+
generation: 0,
93+
byteOffset: 0,
94+
fileSize: 0,
95+
},
96+
],
97+
};
98+
}
99+
100+
function commitCheckpoint(
101+
mainPath: string,
102+
root: string,
103+
baseRevision: number,
104+
extras: Omit<
105+
RawTranscriptSessionCommitOptions,
106+
'markerDir' | 'allowedMarkerRoots'
107+
> = {}
108+
): Promise<void> {
109+
return commitRawTranscriptSessionCheckpoint(
110+
mainPath,
111+
checkpoint(mainPath, baseRevision),
112+
commitOptions(root, extras)
113+
);
52114
}
53115

54116
async function tokenSnapshot(
@@ -91,40 +153,38 @@ function errorMessage(error: unknown): string {
91153

92154
describe('raw transcript session lock three-party schedules', () => {
93155
it('T1 keeps fresh owner B canonical while displaced owner A releases', async () => {
94-
const { markerPath, lockPath } = await fixture('t1-displaced-owner');
156+
const { root, mainPath, lockPath } = await fixture('t1-displaced-owner');
95157
const holdA = deferred();
96158
const aEntered = deferred();
97159
const releaseBarrier = deferred();
98160
const aReleasePaused = deferred();
99161
const holdB = deferred();
100162
const bEntered = deferred();
101163

102-
const ownerA = withRawTranscriptSessionMarkerLock(
103-
markerPath,
104-
async () => {
105-
aEntered.resolve();
106-
await holdA.promise;
107-
},
108-
{
164+
const ownerA = commitCheckpoint(mainPath, root, 0, {
165+
lock: {
109166
onAfterReleaseTokensUnlinkedBeforeRmdir: async () => {
110167
aReleasePaused.resolve();
111168
await releaseBarrier.promise;
112169
},
113-
}
114-
);
170+
},
171+
onLocked: async () => {
172+
aEntered.resolve();
173+
await holdA.promise;
174+
},
175+
});
115176
await aEntered.promise;
116177

117-
const ownerB = withRawTranscriptSessionMarkerLock(
118-
markerPath,
119-
async () => {
178+
const ownerB = commitCheckpoint(mainPath, root, 1, {
179+
lock: {
180+
now: () => Date.now() + STALE_MS + 5_000,
181+
isProcessAlive: () => false,
182+
},
183+
onLocked: async () => {
120184
bEntered.resolve();
121185
await holdB.promise;
122186
},
123-
{
124-
now: () => Date.now() + STALE_MS + 5_000,
125-
isProcessAlive: () => false,
126-
}
127-
);
187+
});
128188
await bEntered.promise;
129189
const ownerBBeforeRelease = await tokenSnapshot(lockPath);
130190

@@ -149,51 +209,48 @@ describe('raw transcript session lock three-party schedules', () => {
149209
});
150210

151211
it('T2 rejects interloper C while displaced owner A release is in flight', async () => {
152-
const { markerPath } = await fixture('t2-release-interloper');
212+
const { root, mainPath } = await fixture('t2-release-interloper');
153213
const holdA = deferred();
154214
const aEntered = deferred();
155215
const releaseBarrier = deferred();
156216
const aReleasePaused = deferred();
157217
const holdB = deferred();
158218
const bEntered = deferred();
159219

160-
const ownerA = withRawTranscriptSessionMarkerLock(
161-
markerPath,
162-
async () => {
163-
aEntered.resolve();
164-
await holdA.promise;
165-
},
166-
{
220+
const ownerA = commitCheckpoint(mainPath, root, 0, {
221+
lock: {
167222
onAfterReleaseTokensUnlinkedBeforeRmdir: async () => {
168223
aReleasePaused.resolve();
169224
await releaseBarrier.promise;
170225
},
171-
}
172-
);
226+
},
227+
onLocked: async () => {
228+
aEntered.resolve();
229+
await holdA.promise;
230+
},
231+
});
173232
await aEntered.promise;
174-
const ownerB = withRawTranscriptSessionMarkerLock(
175-
markerPath,
176-
async () => {
233+
const ownerB = commitCheckpoint(mainPath, root, 1, {
234+
lock: {
235+
now: () => Date.now() + STALE_MS + 5_000,
236+
isProcessAlive: () => false,
237+
},
238+
onLocked: async () => {
177239
bEntered.resolve();
178240
await holdB.promise;
179241
},
180-
{
181-
now: () => Date.now() + STALE_MS + 5_000,
182-
isProcessAlive: () => false,
183-
}
184-
);
242+
});
185243
await bEntered.promise;
186244

187245
holdA.resolve();
188246
await aReleasePaused.promise;
189247
let interloperEntered = false;
190-
const interloperError = await withRawTranscriptSessionMarkerLock(
191-
markerPath,
192-
async () => {
248+
const interloperError = await commitCheckpoint(mainPath, root, 2, {
249+
lock: FAST_ACQUIRE,
250+
onLocked: () => {
193251
interloperEntered = true;
194252
},
195-
FAST_ACQUIRE
196-
).then(
253+
}).then(
197254
() => null,
198255
(error: unknown) => error
199256
);
@@ -213,32 +270,29 @@ describe('raw transcript session lock three-party schedules', () => {
213270
});
214271

215272
it('T3 rejects interloper C while stale owner A reclamation is in flight', async () => {
216-
const { markerPath, lockPath } = await fixture('t3-reclaim-interloper');
273+
const { root, mainPath, lockPath } = await fixture('t3-reclaim-interloper');
217274
await plantStaleToken(lockPath);
218275
const reclaimBarrier = deferred();
219276
const reclaimerPaused = deferred();
220277

221-
const reclaimer = withRawTranscriptSessionMarkerLock(
222-
markerPath,
223-
async () => undefined,
224-
{
278+
const reclaimer = commitCheckpoint(mainPath, root, 0, {
279+
lock: {
225280
isProcessAlive: () => false,
226281
onAfterExpiredTokensUnlinkedBeforeRmdir: async () => {
227282
reclaimerPaused.resolve();
228283
await reclaimBarrier.promise;
229284
},
230-
}
231-
);
285+
},
286+
});
232287
await reclaimerPaused.promise;
233288

234289
let interloperEntered = false;
235-
const interloperError = await withRawTranscriptSessionMarkerLock(
236-
markerPath,
237-
async () => {
290+
const interloperError = await commitCheckpoint(mainPath, root, 0, {
291+
lock: FAST_ACQUIRE,
292+
onLocked: () => {
238293
interloperEntered = true;
239294
},
240-
FAST_ACQUIRE
241-
).then(
295+
}).then(
242296
() => null,
243297
(error: unknown) => error
244298
);

tests/raw-transcript-lock.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,32 @@ describe('raw transcript session lock adapter', () => {
202202
await expectMissing(lockPath);
203203
});
204204

205+
it('does not reclaim a stale-mtime token whose createdAt is still fresh', async () => {
206+
const { markerPath, lockPath } = await fixture(
207+
'stale-mtime-fresh-created-at'
208+
);
209+
const planted = await plantToken(lockPath, {
210+
stale: true,
211+
pid: DEAD_PID,
212+
createdAt: Date.now(),
213+
});
214+
const before = await readFile(planted, 'utf8');
215+
let actionRan = false;
216+
217+
await expect(
218+
withRawTranscriptSessionMarkerLock(
219+
markerPath,
220+
async () => {
221+
actionRan = true;
222+
},
223+
FAST_ACQUIRE
224+
)
225+
).rejects.toThrow(`Timed out acquiring session marker lock '${lockPath}'`);
226+
227+
expect(actionRan).toBe(false);
228+
expect(await readFile(planted, 'utf8')).toBe(before);
229+
});
230+
205231
it('does not reclaim a stale lock whose pid is still alive', async () => {
206232
const { markerPath, lockPath } = await fixture('stale-alive');
207233
const planted = await plantToken(lockPath, {

0 commit comments

Comments
 (0)