Repository navigation
🤖 tests: add the memory and foreign-holder SessionLocks layers to the contract #7367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
e3cfd1c
tests: add the memory and foreign-holder SessionLocks layers to the c…
ThomasK33 30fe6b1
tests: match the Node locks in the fake layers on abort, missing pare…
ThomasK33 9675e47
tests: check the signal first and the parent again after the take in …
ThomasK33 f752502
tests: leave non-creatable record locks on the Node locks and count f…
ThomasK33 423f813
tests: assert the two rejections without awaiting a void matcher
ThomasK33 991b02f
Merge remote-tracking branch 'origin/main' into phase4/fakes
ThomasK33 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
218 changes: 218 additions & 0 deletions
218
src/node/services/sessionLocks/foreignHolders.testHarness.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| /** | ||
| * A SessionLocks layer that plays other backends: a test puts a live or dead | ||
| * foreign holder on a lock path, and the wrapped layer refuses or reclaims as | ||
| * the Node locks would, with today's text. A live holder refuses at once (the | ||
| * Node locks would wait the whole timeout first), so tests need no real wait. | ||
| */ | ||
| import assert from "node:assert"; | ||
| import * as path from "node:path"; | ||
| import { Effect, Layer } from "effect"; | ||
| import { SessionLocksTag } from "@/node/services/di/tags"; | ||
| import { | ||
| workspaceMutationInProgressMessage, | ||
| workspaceMutationLockPath, | ||
| workspaceUseLockDir, | ||
| } from "@/node/services/workspaceUseLeases"; | ||
| import { fileLockTimeoutMessage, type FileLockBlocker } from "@/node/utils/concurrency/fileLock"; | ||
| import { | ||
| CrossProcessLockTimeoutError, | ||
| WorkspaceMutationInProgressError, | ||
| } from "@/node/utils/concurrency/lockErrors"; | ||
| import { getSelfIdentity, type ProcessIdentity } from "@/node/utils/concurrency/processLiveness"; | ||
| import { | ||
| crossProcessLockTimeoutMessage, | ||
| describeHolder, | ||
| type CrossProcessLockHolder, | ||
| } from "@/node/utils/main/crossProcessLock"; | ||
| import type { SessionLocks } from "./sessionLocks"; | ||
|
|
||
| /** | ||
| * The Node locks' reason for a live holder with a current record in this PID | ||
| * domain (processLiveness.judgeHolder). A record without an identity takes the | ||
| * legacy branch, which words it otherwise, so every fake holder has one. | ||
| */ | ||
| const LIVE_WHY = process.platform === "linux" ? "that process is running" : "that pid is running"; | ||
|
|
||
| /** | ||
| * The identity a fake foreign holder records: another backend on this host, so | ||
| * the same platform, boot and PID namespace. It borrows this process's start | ||
| * time, which only describeHolder prints. | ||
| */ | ||
| export function foreignIdentity(): ProcessIdentity { | ||
| return getSelfIdentity(); | ||
| } | ||
|
|
||
| /** A fake holder pid: n > 0 apart from this process, so it is never this process. */ | ||
| export function foreignPid(n = 1): number { | ||
| assert(Number.isInteger(n) && n > 0, "foreignPid needs a positive integer"); | ||
| return process.pid + n; | ||
| } | ||
|
|
||
| /** Why the Node locks refuse a live holder with this pid: the blocker of their timeout text. */ | ||
| export function liveBlocker( | ||
| pid: number, | ||
| identity: ProcessIdentity = foreignIdentity() | ||
| ): FileLockBlocker { | ||
| return { holder: { pid, identity }, why: LIVE_WHY }; | ||
| } | ||
|
|
||
| /** The record a live foreign holder would have written: describeHolder reads pid and identity. */ | ||
| export function foreignRecord( | ||
| pid: number, | ||
| identity: ProcessIdentity = foreignIdentity() | ||
| ): CrossProcessLockHolder { | ||
| return { pid, token: `foreign-${pid}`, acquiredAt: 0, identity }; | ||
| } | ||
|
|
||
| /** The error of a Node acquire whose signal is aborted: it checks the signal before any attempt. */ | ||
| export function abortedAcquireError(): Error { | ||
| return new Error("Lock acquisition was aborted"); | ||
| } | ||
|
|
||
| /** The handle that the test keeps. It holds the foreign holders, not the locks. */ | ||
| export interface ForeignHolders { | ||
| /** | ||
| * Another backend holds lockPath. live: refuse at once. | ||
| * dead: the next acquire reclaims it, and inspect says "dead" until then. | ||
| */ | ||
| hold(lockPath: string, state: "live" | "dead", holder?: { pid: number }): void; | ||
| /** The foreign holder of lockPath released it cleanly. */ | ||
| release(lockPath: string): void; | ||
| /** Paths whose dead holder an acquire reclaimed, in order. */ | ||
| readonly reclaimed: readonly string[]; | ||
| } | ||
|
|
||
| interface Holder { | ||
| state: "live" | "dead"; | ||
| pid: number; | ||
| } | ||
|
|
||
| /** The test's side of makeForeignHolders: wrapWithForeignHolders reads the same map. */ | ||
| const holdersOf = new WeakMap<ForeignHolders, Map<string, Holder>>(); | ||
|
|
||
| export function makeForeignHolders(): ForeignHolders { | ||
| const table = new Map<string, Holder>(); | ||
| const reclaimed: string[] = []; | ||
| const holders: ForeignHolders = { | ||
| hold: (lockPath, state, holder) => { | ||
| const pid = holder?.pid ?? foreignPid(); | ||
| assert(pid !== process.pid, "a foreign holder must not be this process"); | ||
| table.set(lockPath, { state, pid }); | ||
| }, | ||
| release: (lockPath) => { | ||
| assert(table.delete(lockPath), `no foreign holder on ${lockPath}`); | ||
| }, | ||
| reclaimed, | ||
| }; | ||
| holdersOf.set(holders, table); | ||
| return holders; | ||
| } | ||
|
|
||
| /** | ||
| * The locks that check the foreign holders before each call. A dead holder is | ||
| * reclaimed by the acquire that meets it: it leaves the table, the path goes to | ||
| * `reclaimed`, and the call goes on to the inner locks. | ||
| */ | ||
| export function wrapWithForeignHolders(inner: SessionLocks, holders: ForeignHolders): SessionLocks { | ||
| const table = holdersOf.get(holders); | ||
| assert(table !== undefined, "holders must come from makeForeignHolders"); | ||
| const reclaimed = holders.reclaimed as string[]; | ||
| /** The live holder of lockPath, after it reclaims a dead one. */ | ||
| const liveHolder = (lockPath: string): Holder | undefined => { | ||
| const holder = table.get(lockPath); | ||
| if (holder?.state === "dead") { | ||
| table.delete(lockPath); | ||
| reclaimed.push(lockPath); | ||
| return undefined; | ||
| } | ||
| return holder; | ||
| }; | ||
| // One object per config, as workspaceUseLeasesFor gives. | ||
| const leasesByConfig = new WeakMap<object, ReturnType<SessionLocks["useLeasesFor"]>>(); | ||
| return { | ||
| workspaceFiles: inner.workspaceFiles, | ||
| acquireProcessFileLock: async (options) => { | ||
| const holder = liveHolder(options.lockPath); | ||
| if (holder !== undefined) { | ||
| throw new Error( | ||
| fileLockTimeoutMessage( | ||
| options.label, | ||
| options.lockPath, | ||
| options.timeoutMs, | ||
| liveBlocker(holder.pid) | ||
| ) | ||
| ); | ||
| } | ||
| return await inner.acquireProcessFileLock(options); | ||
| }, | ||
| acquireCrossProcessLock: async (options) => { | ||
| // crossProcessLock.ts checks the signal before each attempt: abort wins over a holder. | ||
| if (options.signal?.aborted) throw abortedAcquireError(); | ||
| const holder = liveHolder(options.lockPath); | ||
| if (holder !== undefined) { | ||
| throw new CrossProcessLockTimeoutError( | ||
|
ThomasK33 marked this conversation as resolved.
Resolved
|
||
| crossProcessLockTimeoutMessage(options.timeoutMessage, options.lockPath, { | ||
| holder: foreignRecord(holder.pid), | ||
| why: LIVE_WHY, | ||
| }) | ||
| ); | ||
| } | ||
| return await inner.acquireCrossProcessLock(options); | ||
| }, | ||
| // A probe never reclaims: it reports what an acquire would do. | ||
| inspectCrossProcessLock: async (lockPath) => { | ||
| const holder = table.get(lockPath); | ||
| if (holder === undefined) return await inner.inspectCrossProcessLock(lockPath); | ||
| if (holder.state === "dead") return { state: "dead" }; | ||
| return { state: "held", holder: describeHolder(foreignRecord(holder.pid)), why: LIVE_WHY }; | ||
| }, | ||
| useLeasesFor: (config) => { | ||
| const cached = leasesByConfig.get(config); | ||
| if (cached !== undefined) return cached; | ||
| const leases = inner.useLeasesFor(config); | ||
| const wrapped: ReturnType<SessionLocks["useLeasesFor"]> = { | ||
| // A live foreign mutator holds the gate. The inner hold reads the gate on | ||
| // disk, where a fake holder leaves no file. Like WorkspaceUseLeases.hold, the | ||
| // lease is published first and the gate probed second, so a mutator that | ||
| // takes the gate meanwhile is not missed. | ||
| hold: async (workspaceId, kind) => { | ||
| const lease = await leases.hold(workspaceId, kind); | ||
| const gate = table.get(workspaceMutationLockPath(config.rootDir, workspaceId)); | ||
| if (gate?.state === "live") { | ||
|
ThomasK33 marked this conversation as resolved.
Resolved
|
||
| await lease.release(); | ||
| throw new WorkspaceMutationInProgressError( | ||
| workspaceMutationInProgressMessage(workspaceId, { | ||
| holder: describeHolder(foreignRecord(gate.pid)), | ||
| why: LIVE_WHY, | ||
| }) | ||
| ); | ||
| } | ||
| return lease; | ||
| }, | ||
| // WorkspaceUseLeases.isHeld lists every backend's "<token>.<kind>.lock" in the | ||
| // workspace's lease folder. A live fake there is another backend's lease. | ||
| isHeld: async (workspaceId, kind) => { | ||
| const dir = workspaceUseLockDir(config.rootDir, workspaceId); | ||
| for (const [lockPath, holder] of table) { | ||
| const lease = path.dirname(lockPath) === dir && lockPath.endsWith(`.${kind}.lock`); | ||
| if (lease && holder.state === "live") return true; | ||
| } | ||
| return await leases.isHeld(workspaceId, kind); | ||
| }, | ||
| }; | ||
| leasesByConfig.set(config, wrapped); | ||
| return wrapped; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** A layer that builds inner, then checks the foreign holders before each call. */ | ||
| export function withForeignHolders( | ||
| inner: Layer.Layer<SessionLocksTag>, | ||
| holders: ForeignHolders | ||
| ): Layer.Layer<SessionLocksTag> { | ||
| return Layer.effect( | ||
| SessionLocksTag, | ||
| Effect.map(SessionLocksTag, (locks) => wrapWithForeignHolders(locks, holders)) | ||
| ).pipe(Layer.provide(inner)); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.