| author | |
| committer | |
| log | 37de6cbde65c7cf9ba90a2557c232e63b7e16988 |
| tree | c51f721ea8412070c800408151542e7939271184 |
| parent | 1beb0355d05bbe4a780b238e3138f0a8ed99df5f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>15 files changed, 227 insertions(+), 233 deletions(-)
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts+3-7| ... | ... | @@ -78,6 +78,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 78 | 78 | import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; |
| 79 | 79 | import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; |
| 80 | 80 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; |
| 81 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 81 | 82 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; |
| 82 | 83 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 83 | 84 | |
| ... | ... | @@ -887,18 +888,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr |
| 887 | 888 | const stagedReverts = new Set<string>(); |
| 888 | 889 | // Starting a turn and cutting the history take turns on a session: each |
| 889 | 890 | // checks that the other is not running before its own requests yield. |
| 890 | const sessionGates = new Map<string, Semaphore.Semaphore>(); | |
| 891 | const sessionGates = yield* KeyedLock.make<string>(); | |
| 891 | 892 | const exclusive = |
| 892 | 893 | (providerThread: OrchestrationV2ProviderThread) => |
| 893 | 894 | <A, E, R>(effect: Effect.Effect<A, E, R>) => { |
| 894 | 895 | const sessionId = providerThread.nativeThreadRef?.nativeId; |
| 895 | 896 | if (sessionId == null) return effect; |
| 896 | let gate = sessionGates.get(sessionId); | |
| 897 | if (gate === undefined) { | |
| 898 | gate = Semaphore.makeUnsafe(1); | |
| 899 | sessionGates.set(sessionId, gate); | |
| 900 | } | |
| 901 | return gate.withPermit(effect); | |
| 897 | return sessionGates.withLock(sessionId, effect); | |
| 902 | 898 | }; |
| 903 | 899 | const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => |
| 904 | 900 | Queue.offer(events, event).pipe(Effect.asVoid); |
apps/server/src/orchestration-v2/CheckpointService.ts+3-24| ... | ... | @@ -14,10 +14,9 @@ import * as Context from "effect/Context"; |
| 14 | 14 | import * as DateTime from "effect/DateTime"; |
| 15 | 15 | import * as Effect from "effect/Effect"; |
| 16 | 16 | import * as Encoding from "effect/Encoding"; |
| 17 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 17 | 18 | import * as Layer from "effect/Layer"; |
| 18 | import * as Ref from "effect/Ref"; | |
| 19 | 19 | import * as Schema from "effect/Schema"; |
| 20 | import * as Semaphore from "effect/Semaphore"; | |
| 21 | 20 | |
| 22 | 21 | import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; |
| 23 | 22 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| ... | ... | @@ -249,29 +248,9 @@ export const layer: Layer.Layer< |
| 249 | 248 | Effect.gen(function* () { |
| 250 | 249 | const checkpointStore = yield* CheckpointStore.CheckpointStore; |
| 251 | 250 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 252 | const workspaceSemaphores = yield* Ref.make(new Map<string, Semaphore.Semaphore>()); | |
| 253 | ||
| 254 | const getWorkspaceSemaphore = (cwd: string) => | |
| 255 | Effect.gen(function* () { | |
| 256 | const existing = (yield* Ref.get(workspaceSemaphores)).get(cwd); | |
| 257 | if (existing !== undefined) { | |
| 258 | return existing; | |
| 259 | } | |
| 260 | ||
| 261 | const created = yield* Semaphore.make(1); | |
| 262 | return yield* Ref.modify(workspaceSemaphores, (current) => { | |
| 263 | const concurrent = current.get(cwd); | |
| 264 | if (concurrent !== undefined) { | |
| 265 | return [concurrent, current]; | |
| 266 | } | |
| 267 | const updated = new Map(current); | |
| 268 | updated.set(cwd, created); | |
| 269 | return [created, updated]; | |
| 270 | }); | |
| 271 | }); | |
| 272 | ||
| 251 | const workspaceLocks = yield* KeyedLock.make<string>(); | |
| 273 | 252 | const withWorkspaceLock = <A, E, R>(cwd: string, effect: Effect.Effect<A, E, R>) => |
| 274 | Effect.flatMap(getWorkspaceSemaphore(cwd), (semaphore) => semaphore.withPermits(1)(effect)); | |
| 253 | workspaceLocks.withLock(cwd, effect); | |
| 275 | 254 | |
| 276 | 255 | const isGitCheckpointable = (cwd: string) => |
| 277 | 256 | checkpointStore.isGitRepository(cwd).pipe(Effect.orElseSucceed(() => false)); |
apps/server/src/orchestration-v2/KeyedSerialExecutor.test.ts deleted-67| ... | ... | @@ -1,67 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Queue from "effect/Queue"; | |
| 7 | import * as Ref from "effect/Ref"; | |
| 8 | ||
| 9 | import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; | |
| 10 | ||
| 11 | describe("makeKeyedSerialExecutor", () => { | |
| 12 | it.effect("allows unrelated keys to run concurrently", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const executor = yield* makeKeyedSerialExecutor<string>(); | |
| 15 | const arrivals = yield* Queue.unbounded<string>(); | |
| 16 | const release = yield* Deferred.make<void>(); | |
| 17 | const run = (key: string) => | |
| 18 | executor.withLock( | |
| 19 | key, | |
| 20 | Queue.offer(arrivals, key).pipe(Effect.andThen(Deferred.await(release))), | |
| 21 | ); | |
| 22 | ||
| 23 | const fibers = yield* Effect.forEach(["a", "b"], run, { | |
| 24 | concurrency: "unbounded", | |
| 25 | discard: false, | |
| 26 | }).pipe(Effect.forkChild); | |
| 27 | const observed = [yield* Queue.take(arrivals), yield* Queue.take(arrivals)]; | |
| 28 | yield* Deferred.succeed(release, undefined); | |
| 29 | yield* Fiber.join(fibers); | |
| 30 | expect(new Set(observed)).toEqual(new Set(["a", "b"])); | |
| 31 | }).pipe(Effect.timeout("1 second")), | |
| 32 | ); | |
| 33 | ||
| 34 | it.effect("serializes work for the same key", () => | |
| 35 | Effect.gen(function* () { | |
| 36 | const executor = yield* makeKeyedSerialExecutor<string>(); | |
| 37 | const firstEntered = yield* Deferred.make<void>(); | |
| 38 | const releaseFirst = yield* Deferred.make<void>(); | |
| 39 | const events = yield* Ref.make<Array<string>>([]); | |
| 40 | ||
| 41 | const first = yield* executor | |
| 42 | .withLock( | |
| 43 | "thread", | |
| 44 | Ref.update(events, (current) => [...current, "first:start"]).pipe( | |
| 45 | Effect.andThen(Deferred.succeed(firstEntered, undefined)), | |
| 46 | Effect.andThen(Deferred.await(releaseFirst)), | |
| 47 | Effect.andThen(Ref.update(events, (current) => [...current, "first:end"])), | |
| 48 | ), | |
| 49 | ) | |
| 50 | .pipe(Effect.forkChild); | |
| 51 | yield* Deferred.await(firstEntered); | |
| 52 | const second = yield* executor | |
| 53 | .withLock( | |
| 54 | "thread", | |
| 55 | Ref.update(events, (current) => [...current, "second:start"]), | |
| 56 | ) | |
| 57 | .pipe(Effect.forkChild); | |
| 58 | ||
| 59 | yield* Effect.yieldNow; | |
| 60 | expect(yield* Ref.get(events)).toEqual(["first:start"]); | |
| 61 | yield* Deferred.succeed(releaseFirst, undefined); | |
| 62 | yield* Fiber.join(first); | |
| 63 | yield* Fiber.join(second); | |
| 64 | expect(yield* Ref.get(events)).toEqual(["first:start", "first:end", "second:start"]); | |
| 65 | }).pipe(Effect.timeout("1 second")), | |
| 66 | ); | |
| 67 | }); |
apps/server/src/orchestration-v2/KeyedSerialExecutor.ts deleted-55| ... | ... | @@ -1,55 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Ref from "effect/Ref"; | |
| 3 | import * as Semaphore from "effect/Semaphore"; | |
| 4 | ||
| 5 | interface LockEntry { | |
| 6 | readonly semaphore: Semaphore.Semaphore; | |
| 7 | readonly users: number; | |
| 8 | } | |
| 9 | ||
| 10 | export interface KeyedSerialExecutor<Key> { | |
| 11 | readonly withLock: <A, E, R>(key: Key, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>; | |
| 12 | } | |
| 13 | ||
| 14 | /** | |
| 15 | * Serializes work that targets the same domain identity without coupling | |
| 16 | * unrelated identities to a process-wide mutex. | |
| 17 | */ | |
| 18 | export const makeKeyedSerialExecutor = <Key>(): Effect.Effect<KeyedSerialExecutor<Key>> => | |
| 19 | Effect.gen(function* () { | |
| 20 | const locks = yield* Ref.make(new Map<Key, LockEntry>()); | |
| 21 | ||
| 22 | const acquireLock = (key: Key) => | |
| 23 | Effect.gen(function* () { | |
| 24 | const candidate = yield* Semaphore.make(1); | |
| 25 | return yield* Ref.modify(locks, (current) => { | |
| 26 | const existing = current.get(key); | |
| 27 | const semaphore = existing?.semaphore ?? candidate; | |
| 28 | const next = new Map(current); | |
| 29 | next.set(key, { semaphore, users: (existing?.users ?? 0) + 1 }); | |
| 30 | return [semaphore, next] as const; | |
| 31 | }); | |
| 32 | }); | |
| 33 | ||
| 34 | const releaseLock = (key: Key) => | |
| 35 | Ref.update(locks, (current) => { | |
| 36 | const existing = current.get(key); | |
| 37 | if (existing === undefined) return current; | |
| 38 | const next = new Map(current); | |
| 39 | if (existing.users === 1) { | |
| 40 | next.delete(key); | |
| 41 | } else { | |
| 42 | next.set(key, { ...existing, users: existing.users - 1 }); | |
| 43 | } | |
| 44 | return next; | |
| 45 | }); | |
| 46 | ||
| 47 | return { | |
| 48 | withLock: (key, effect) => | |
| 49 | Effect.acquireUseRelease( | |
| 50 | acquireLock(key), | |
| 51 | (semaphore) => semaphore.withPermit(effect), | |
| 52 | () => releaseLock(key), | |
| 53 | ), | |
| 54 | } satisfies KeyedSerialExecutor<Key>; | |
| 55 | }); |
apps/server/src/orchestration-v2/ProviderSessionManager.ts+4-4| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 1 | 2 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 2 | 3 | import { |
| 3 | 4 | ModelSelection, |
| ... | ... | @@ -36,7 +37,6 @@ import * as ServerSettings from "../serverSettings.ts"; |
| 36 | 37 | import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; |
| 37 | 38 | import * as EventSink from "./EventSink.ts"; |
| 38 | 39 | import * as IdAllocator from "./IdAllocator.ts"; |
| 39 | import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; | |
| 40 | 40 | import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; |
| 41 | 41 | import { |
| 42 | 42 | ProviderAdapterEventStreamError, |
| ... | ... | @@ -386,9 +386,9 @@ export const layerWithOptions = ( |
| 386 | 386 | // cannot drop cleanup for threads only the earlier session served. |
| 387 | 387 | const releaseRecordRetries = yield* FiberSet.make(); |
| 388 | 388 | const nextSubscriberId = yield* Ref.make(0); |
| 389 | const sessionOpen = yield* makeKeyedSerialExecutor<ProviderSessionId>(); | |
| 389 | const sessionOpen = yield* KeyedLock.make<ProviderSessionId>(); | |
| 390 | 390 | // Orders a thread's attach against a detach unloading it on the same session. |
| 391 | const threadAttachment = yield* makeKeyedSerialExecutor<string>(); | |
| 391 | const threadAttachment = yield* KeyedLock.make<string>(); | |
| 392 | 392 | const threadAttachmentKey = (input: { |
| 393 | 393 | readonly providerSessionId: ProviderSessionId; |
| 394 | 394 | readonly threadId: ThreadId; |
| ... | ... | @@ -427,7 +427,7 @@ export const layerWithOptions = ( |
| 427 | 427 | }; |
| 428 | 428 | const isMcpCredentialReserved = (threadId: ThreadId, mcpCredentialId: string) => |
| 429 | 429 | (mcpCredentialReservations.get(mcpReservationKey(threadId, mcpCredentialId)) ?? 0) > 0; |
| 430 | const mcpPrepareLock = yield* makeKeyedSerialExecutor<ThreadId>(); | |
| 430 | const mcpPrepareLock = yield* KeyedLock.make<ThreadId>(); | |
| 431 | 431 | /** |
| 432 | 432 | * Resolves (or mints) the thread's MCP credential and returns it with a |
| 433 | 433 | * reservation held; the caller must drop the reservation exactly once. |
apps/server/src/orchestration-v2/ThreadCommandExecutor.ts+3-4| ... | ... | @@ -1,13 +1,12 @@ |
| 1 | 1 | import type { ThreadId } from "@t3tools/contracts"; |
| 2 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 2 | 3 | import * as Context from "effect/Context"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| 4 | 5 | |
| 5 | import { makeKeyedSerialExecutor, type KeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; | |
| 6 | ||
| 7 | 6 | /** Shared by thread commands and project deletion so both plan against current thread state. */ |
| 8 | 7 | export class ThreadCommandExecutor extends Context.Service< |
| 9 | 8 | ThreadCommandExecutor, |
| 10 | KeyedSerialExecutor<ThreadId> | |
| 9 | KeyedLock.KeyedLock<ThreadId> | |
| 11 | 10 | >()("t3/orchestration-v2/ThreadCommandExecutor") {} |
| 12 | 11 | |
| 13 | export const layer = Layer.effect(ThreadCommandExecutor, makeKeyedSerialExecutor<ThreadId>()); | |
| 12 | export const layer = Layer.effect(ThreadCommandExecutor, KeyedLock.make<ThreadId>()); |
apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts+2-2| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | ThreadPullRequestLink, |
| 22 | 22 | TurnItemId, |
| 23 | 23 | } from "@t3tools/contracts"; |
| 24 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 24 | 25 | import * as Context from "effect/Context"; |
| 25 | 26 | import * as DateTime from "effect/DateTime"; |
| 26 | 27 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -30,7 +31,6 @@ import * as Schema from "effect/Schema"; |
| 30 | 31 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 31 | 32 | |
| 32 | 33 | import * as EventSink from "../EventSink.ts"; |
| 33 | import { makeKeyedSerialExecutor } from "../KeyedSerialExecutor.ts"; | |
| 34 | 34 | import { randomUuidV4 } from "../RandomUuid.ts"; |
| 35 | 35 | |
| 36 | 36 | const IMPORT_EVENT_PREFIX = "migration:v1"; |
| ... | ... | @@ -347,7 +347,7 @@ function chunks<A>(items: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A |
| 347 | 347 | const make = Effect.gen(function* () { |
| 348 | 348 | const sql = yield* SqlClient.SqlClient; |
| 349 | 349 | const eventSink = yield* EventSink.EventSinkV2; |
| 350 | const transcriptImports = yield* makeKeyedSerialExecutor<ThreadId>(); | |
| 350 | const transcriptImports = yield* KeyedLock.make<ThreadId>(); | |
| 351 | 351 | |
| 352 | 352 | const listMessages = (threadId: ThreadId) => |
| 353 | 353 | sql<LegacyMessageRow>` |
apps/server/src/project/ProjectService.ts+3-3| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | type ProjectSnapshot, |
| 9 | 9 | type ThreadId, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 11 | 12 | import * as Context from "effect/Context"; |
| 12 | 13 | import * as DateTime from "effect/DateTime"; |
| 13 | 14 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -18,7 +19,6 @@ import * as Schema from "effect/Schema"; |
| 18 | 19 | |
| 19 | 20 | import * as EventSink from "../orchestration-v2/EventSink.ts"; |
| 20 | 21 | import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; |
| 21 | import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; | |
| 22 | 22 | import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; |
| 23 | 23 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 24 | 24 | import { |
| ... | ... | @@ -155,8 +155,8 @@ export const make = Effect.gen(function* () { |
| 155 | 155 | const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; |
| 156 | 156 | // Commands for one project run in order. Commands that claim a workspace root |
| 157 | 157 | // also hold that root, so two projects cannot both claim it. |
| 158 | const projectLocks = yield* makeKeyedSerialExecutor<ProjectId>(); | |
| 159 | const workspaceLocks = yield* makeKeyedSerialExecutor<string>(); | |
| 158 | const projectLocks = yield* KeyedLock.make<ProjectId>(); | |
| 159 | const workspaceLocks = yield* KeyedLock.make<string>(); | |
| 160 | 160 | |
| 161 | 161 | const toProject = ( |
| 162 | 162 | row: ProjectStore.ProjectRow, |
apps/server/src/provider/providerMaintenanceCommandCoordinator.ts+6-27| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 1 | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | import * as Ref from "effect/Ref"; |
| 3 | import * as Semaphore from "effect/Semaphore"; | |
| 4 | 4 | |
| 5 | 5 | export interface ProviderMaintenanceCommandCoordinatorShape<E> { |
| 6 | 6 | readonly withCommandLock: <A, R>(input: { |
| ... | ... | @@ -15,7 +15,7 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( |
| 15 | 15 | "makeProviderMaintenanceCommandCoordinator", |
| 16 | 16 | )(function* <E>(input: { readonly makeAlreadyRunningError: (targetKey: string) => E }) { |
| 17 | 17 | const runningTargetsRef = yield* Ref.make<ReadonlySet<string>>(new Set()); |
| 18 | const locksRef = yield* Ref.make<ReadonlyMap<string, Semaphore.Semaphore>>(new Map()); | |
| 18 | const locks = yield* KeyedLock.make<string>(); | |
| 19 | 19 | |
| 20 | 20 | const acquireTarget = Effect.fn("acquireTarget")(function* (targetKey: string) { |
| 21 | 21 | return yield* Ref.modify(runningTargetsRef, (runningTargets) => { |
| ... | ... | @@ -35,24 +35,6 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( |
| 35 | 35 | return next; |
| 36 | 36 | }); |
| 37 | 37 | |
| 38 | const getLock = Effect.fn("getProviderMaintenanceCommandLock")(function* (lockKey: string) { | |
| 39 | const existing = (yield* Ref.get(locksRef)).get(lockKey); | |
| 40 | if (existing) { | |
| 41 | return existing; | |
| 42 | } | |
| 43 | ||
| 44 | const lock = yield* Semaphore.make(1); | |
| 45 | return yield* Ref.modify(locksRef, (locks) => { | |
| 46 | const current = locks.get(lockKey); | |
| 47 | if (current) { | |
| 48 | return [current, locks] as const; | |
| 49 | } | |
| 50 | const next = new Map(locks); | |
| 51 | next.set(lockKey, lock); | |
| 52 | return [lock, next] as const; | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | 38 | const withCommandLock: ProviderMaintenanceCommandCoordinatorShape<E>["withCommandLock"] = ({ |
| 57 | 39 | targetKey, |
| 58 | 40 | lockKey, |
| ... | ... | @@ -65,13 +47,10 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( |
| 65 | 47 | return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); |
| 66 | 48 | } |
| 67 | 49 | |
| 68 | return yield* Effect.gen(function* () { | |
| 69 | const lock = yield* getLock(lockKey); | |
| 70 | if (onQueued) { | |
| 71 | yield* onQueued; | |
| 72 | } | |
| 73 | return yield* lock.withPermits(1)(run); | |
| 74 | }).pipe(Effect.ensuring(releaseTarget(targetKey))); | |
| 50 | return yield* (onQueued ?? Effect.void).pipe( | |
| 51 | Effect.andThen(locks.withLock(lockKey, run)), | |
| 52 | Effect.ensuring(releaseTarget(targetKey)), | |
| 53 | ); | |
| 75 | 54 | }); |
| 76 | 55 | |
| 77 | 56 | return { |
apps/server/src/terminal/Manager.ts+3-22| ... | ... | @@ -40,6 +40,7 @@ import { |
| 40 | 40 | ProviderInstanceId, |
| 41 | 41 | } from "@t3tools/contracts"; |
| 42 | 42 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 43 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 43 | 44 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 44 | 45 | import { mergePathEntries } from "@t3tools/shared/shell"; |
| 45 | 46 | |
| ... | ... | @@ -58,7 +59,6 @@ import * as Option from "effect/Option"; |
| 58 | 59 | import * as Path from "effect/Path"; |
| 59 | 60 | import * as Schema from "effect/Schema"; |
| 60 | 61 | import * as Scope from "effect/Scope"; |
| 61 | import * as Semaphore from "effect/Semaphore"; | |
| 62 | 62 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 63 | 63 | |
| 64 | 64 | import * as ServerConfig from "../config.ts"; |
| ... | ... | @@ -1569,7 +1569,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1569 | 1569 | sessions: new Map(), |
| 1570 | 1570 | killFibers: new Map(), |
| 1571 | 1571 | }); |
| 1572 | const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>()); | |
| 1572 | const threadLocks = yield* KeyedLock.make<string>(); | |
| 1573 | 1573 | const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>(); |
| 1574 | 1574 | const workerScope = yield* Scope.make("sequential"); |
| 1575 | 1575 | yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); |
| ... | ... | @@ -1598,29 +1598,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1598 | 1598 | f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], |
| 1599 | 1599 | ) => SynchronizedRef.modify(managerStateRef, f); |
| 1600 | 1600 | |
| 1601 | const getThreadSemaphore = (threadId: string) => | |
| 1602 | SynchronizedRef.modifyEffect(threadLocksRef, (current) => { | |
| 1603 | const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr( | |
| 1604 | current.get(threadId), | |
| 1605 | ); | |
| 1606 | return Option.match(existing, { | |
| 1607 | onNone: () => | |
| 1608 | Semaphore.make(1).pipe( | |
| 1609 | Effect.map((semaphore) => { | |
| 1610 | const next = new Map(current); | |
| 1611 | next.set(threadId, semaphore); | |
| 1612 | return [semaphore, next] as const; | |
| 1613 | }), | |
| 1614 | ), | |
| 1615 | onSome: (semaphore) => Effect.succeed([semaphore, current] as const), | |
| 1616 | }); | |
| 1617 | }); | |
| 1618 | ||
| 1619 | 1601 | const withThreadLock = <A, E, R>( |
| 1620 | 1602 | threadId: string, |
| 1621 | 1603 | effect: Effect.Effect<A, E, R>, |
| 1622 | ): Effect.Effect<A, E, R> => | |
| 1623 | Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); | |
| 1604 | ): Effect.Effect<A, E, R> => threadLocks.withLock(threadId, effect); | |
| 1624 | 1605 | |
| 1625 | 1606 | const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( |
| 1626 | 1607 | process: PtyAdapter.PtyProcess | null, |
apps/server/src/vcs/VcsStatusBroadcaster.ts+4-10| ... | ... | @@ -10,7 +10,6 @@ import * as PubSub from "effect/PubSub"; |
| 10 | 10 | import * as Ref from "effect/Ref"; |
| 11 | 11 | import * as Schedule from "effect/Schedule"; |
| 12 | 12 | import * as Scope from "effect/Scope"; |
| 13 | import * as Semaphore from "effect/Semaphore"; | |
| 14 | 13 | import * as Stream from "effect/Stream"; |
| 15 | 14 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 16 | 15 | import type { |
| ... | ... | @@ -22,6 +21,7 @@ import type { |
| 22 | 21 | VcsStatusStreamEvent, |
| 23 | 22 | } from "@t3tools/contracts"; |
| 24 | 23 | import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 24 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 25 | 25 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 26 | 26 | |
| 27 | 27 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| ... | ... | @@ -234,15 +234,9 @@ export const make = Effect.gen(function* () { |
| 234 | 234 | // One permit per cwd for remote reads that write the cache. Without it a |
| 235 | 235 | // periodic poll that started before `gh pr create` can finish after the |
| 236 | 236 | // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. |
| 237 | const remoteWriteLocks = new Map<string, Semaphore.Semaphore>(); | |
| 238 | const withRemoteWriteLock = <A, E, R>(cwd: string, effect: Effect.Effect<A, E, R>) => { | |
| 239 | let lock = remoteWriteLocks.get(cwd); | |
| 240 | if (lock === undefined) { | |
| 241 | lock = Semaphore.makeUnsafe(1); | |
| 242 | remoteWriteLocks.set(cwd, lock); | |
| 243 | } | |
| 244 | return lock.withPermits(1)(effect); | |
| 245 | }; | |
| 237 | const remoteWriteLocks = yield* KeyedLock.make<string>(); | |
| 238 | const withRemoteWriteLock = <A, E, R>(cwd: string, effect: Effect.Effect<A, E, R>) => | |
| 239 | remoteWriteLocks.withLock(cwd, effect); | |
| 246 | 240 | const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>()); |
| 247 | 241 | |
| 248 | 242 | const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( |
packages/shared/package.json+4| ... | ... | @@ -95,6 +95,10 @@ |
| 95 | 95 | "types": "./src/KeyedCoalescingWorker.ts", |
| 96 | 96 | "import": "./src/KeyedCoalescingWorker.ts" |
| 97 | 97 | }, |
| 98 | "./KeyedLock": { | |
| 99 | "types": "./src/KeyedLock.ts", | |
| 100 | "import": "./src/KeyedLock.ts" | |
| 101 | }, | |
| 98 | 102 | "./schemaJson": { |
| 99 | 103 | "types": "./src/schemaJson.ts", |
| 100 | 104 | "import": "./src/schemaJson.ts" |
packages/shared/src/KeyedLock.test.ts created+127| ... | ... | @@ -0,0 +1,127 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Deferred from "effect/Deferred"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Queue from "effect/Queue"; | |
| 7 | import * as Ref from "effect/Ref"; | |
| 8 | import * as Scope from "effect/Scope"; | |
| 9 | ||
| 10 | import * as KeyedLock from "./KeyedLock.ts"; | |
| 11 | ||
| 12 | describe("KeyedLock", () => { | |
| 13 | it.effect("runs one holder of a key at a time, waiters in the order they queued", () => | |
| 14 | Effect.gen(function* () { | |
| 15 | const lock = yield* KeyedLock.make<string>(); | |
| 16 | const events = yield* Ref.make<ReadonlyArray<string>>([]); | |
| 17 | const record = (event: string) => Ref.update(events, (current) => [...current, event]); | |
| 18 | const firstEntered = yield* Deferred.make<void>(); | |
| 19 | const releaseFirst = yield* Deferred.make<void>(); | |
| 20 | ||
| 21 | const first = yield* lock | |
| 22 | .withLock( | |
| 23 | "key", | |
| 24 | record("first:start").pipe( | |
| 25 | Effect.andThen(Deferred.succeed(firstEntered, undefined)), | |
| 26 | Effect.andThen(Deferred.await(releaseFirst)), | |
| 27 | Effect.andThen(record("first:end")), | |
| 28 | ), | |
| 29 | ) | |
| 30 | .pipe(Effect.forkChild); | |
| 31 | yield* Deferred.await(firstEntered); | |
| 32 | const second = yield* lock.withLock("key", record("second")).pipe(Effect.forkChild); | |
| 33 | const third = yield* lock.withLock("key", record("third")).pipe(Effect.forkChild); | |
| 34 | yield* Effect.yieldNow; | |
| 35 | assert.deepStrictEqual(yield* Ref.get(events), ["first:start"]); | |
| 36 | ||
| 37 | yield* Deferred.succeed(releaseFirst, undefined); | |
| 38 | yield* Fiber.joinAll([first, second, third]); | |
| 39 | assert.deepStrictEqual(yield* Ref.get(events), [ | |
| 40 | "first:start", | |
| 41 | "first:end", | |
| 42 | "second", | |
| 43 | "third", | |
| 44 | ]); | |
| 45 | }), | |
| 46 | ); | |
| 47 | ||
| 48 | it.effect("runs holders of different keys concurrently", () => | |
| 49 | Effect.gen(function* () { | |
| 50 | const lock = yield* KeyedLock.make<string>(); | |
| 51 | const entered = yield* Queue.unbounded<string>(); | |
| 52 | const release = yield* Deferred.make<void>(); | |
| 53 | const hold = (key: string) => | |
| 54 | lock.withLock(key, Queue.offer(entered, key).pipe(Effect.andThen(Deferred.await(release)))); | |
| 55 | ||
| 56 | const holders = yield* Effect.forEach(["a", "b"], hold, { concurrency: "unbounded" }).pipe( | |
| 57 | Effect.forkChild, | |
| 58 | ); | |
| 59 | // Both enter while the other still holds its key. | |
| 60 | const both = new Set([yield* Queue.take(entered), yield* Queue.take(entered)]); | |
| 61 | assert.deepStrictEqual(both, new Set(["a", "b"])); | |
| 62 | yield* Deferred.succeed(release, undefined); | |
| 63 | yield* Fiber.join(holders); | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("keeps no lock for a key once its holders and waiters are gone", () => | |
| 68 | Effect.gen(function* () { | |
| 69 | const lock = yield* KeyedLock.make<string>(); | |
| 70 | const entered = yield* Deferred.make<void>(); | |
| 71 | const release = yield* Deferred.make<void>(); | |
| 72 | ||
| 73 | const holder = yield* lock | |
| 74 | .withLock( | |
| 75 | "key", | |
| 76 | Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))), | |
| 77 | ) | |
| 78 | .pipe(Effect.forkChild); | |
| 79 | yield* Deferred.await(entered); | |
| 80 | // A waiter that gives up and one that runs. | |
| 81 | const abandoned = yield* lock.withLock("key", Effect.void).pipe(Effect.forkChild); | |
| 82 | const waiting = yield* lock.withLock("key", Effect.void).pipe(Effect.forkChild); | |
| 83 | yield* Effect.yieldNow; | |
| 84 | assert.deepStrictEqual(yield* lock.activeKeys, ["key"]); | |
| 85 | ||
| 86 | yield* Fiber.interrupt(abandoned); | |
| 87 | // The holder and the other waiter still need the lock. | |
| 88 | assert.deepStrictEqual(yield* lock.activeKeys, ["key"]); | |
| 89 | yield* Deferred.succeed(release, undefined); | |
| 90 | yield* Fiber.joinAll([holder, waiting]); | |
| 91 | // A holder that fails releases its key too. | |
| 92 | assert.strictEqual( | |
| 93 | yield* lock.withLock("other", Effect.fail("boom")).pipe(Effect.flip), | |
| 94 | "boom", | |
| 95 | ); | |
| 96 | ||
| 97 | assert.deepStrictEqual(yield* lock.activeKeys, []); | |
| 98 | }), | |
| 99 | ); | |
| 100 | ||
| 101 | it.effect("keeps working after the scope that made it closes", () => | |
| 102 | Effect.gen(function* () { | |
| 103 | // Services built per request hand their lock to work that outlives the build. | |
| 104 | const lock = yield* Effect.scoped(KeyedLock.make<string>()); | |
| 105 | assert.strictEqual(yield* lock.withLock("key", Effect.succeed("ran")), "ran"); | |
| 106 | }), | |
| 107 | ); | |
| 108 | ||
| 109 | it.effect("runs the effect in the caller's scope", () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const lock = yield* KeyedLock.make<string>(); | |
| 112 | const finalized = yield* Ref.make(false); | |
| 113 | const callerScope = yield* Scope.make(); | |
| 114 | ||
| 115 | yield* lock | |
| 116 | .withLock( | |
| 117 | "key", | |
| 118 | Effect.addFinalizer(() => Ref.set(finalized, true)), | |
| 119 | ) | |
| 120 | .pipe(Scope.provide(callerScope)); | |
| 121 | // The finalizer belongs to the caller, so releasing the lock leaves it. | |
| 122 | assert.isFalse(yield* Ref.get(finalized)); | |
| 123 | yield* Scope.close(callerScope, Exit.void); | |
| 124 | assert.isTrue(yield* Ref.get(finalized)); | |
| 125 | }), | |
| 126 | ); | |
| 127 | }); |
packages/shared/src/KeyedLock.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Ref from "effect/Ref"; | |
| 3 | import * as Semaphore from "effect/Semaphore"; | |
| 4 | ||
| 5 | export interface KeyedLock<Key> { | |
| 6 | /** Runs `effect` once no other holder of `key` is running. Queued waiters go first-in, first-out. */ | |
| 7 | readonly withLock: <A, E, R>(key: Key, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>; | |
| 8 | /** The keys someone holds or waits on right now. */ | |
| 9 | readonly activeKeys: Effect.Effect<ReadonlyArray<Key>>; | |
| 10 | } | |
| 11 | ||
| 12 | interface LockEntry { | |
| 13 | readonly semaphore: Semaphore.Semaphore; | |
| 14 | readonly users: number; | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * Mutual exclusion per key. A key's lock exists only while someone holds or | |
| 19 | * waits on it, so locks for keys that come and go never accumulate. Not | |
| 20 | * reentrant: taking a key while already holding it deadlocks. | |
| 21 | * | |
| 22 | * Keys compare like `Map` keys (by value for strings and numbers). The lock is | |
| 23 | * not tied to a scope, so it keeps working for as long as anyone references it. | |
| 24 | */ | |
| 25 | export const make = <Key>(): Effect.Effect<KeyedLock<Key>> => | |
| 26 | Effect.gen(function* () { | |
| 27 | const locks = yield* Ref.make<ReadonlyMap<Key, LockEntry>>(new Map()); | |
| 28 | ||
| 29 | const acquire = (key: Key) => | |
| 30 | Effect.flatMap(Semaphore.make(1), (candidate) => | |
| 31 | Ref.modify(locks, (current) => { | |
| 32 | const existing = current.get(key); | |
| 33 | const semaphore = existing?.semaphore ?? candidate; | |
| 34 | const next = new Map(current); | |
| 35 | next.set(key, { semaphore, users: (existing?.users ?? 0) + 1 }); | |
| 36 | return [semaphore, next] as const; | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | const release = (key: Key) => | |
| 41 | Ref.update(locks, (current) => { | |
| 42 | const existing = current.get(key); | |
| 43 | if (existing === undefined) return current; | |
| 44 | const next = new Map(current); | |
| 45 | if (existing.users === 1) { | |
| 46 | next.delete(key); | |
| 47 | } else { | |
| 48 | next.set(key, { ...existing, users: existing.users - 1 }); | |
| 49 | } | |
| 50 | return next; | |
| 51 | }); | |
| 52 | ||
| 53 | return { | |
| 54 | withLock: (key, effect) => | |
| 55 | Effect.acquireUseRelease( | |
| 56 | acquire(key), | |
| 57 | (semaphore) => semaphore.withPermit(effect), | |
| 58 | () => release(key), | |
| 59 | ), | |
| 60 | activeKeys: Effect.map(Ref.get(locks), (current) => Array.from(current.keys())), | |
| 61 | } satisfies KeyedLock<Key>; | |
| 62 | }); |
packages/ssh/src/tunnel.ts+3-8| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | waitForHttpReady as waitForHttpReadyShared, |
| 8 | 8 | } from "@t3tools/shared/httpReadiness"; |
| 9 | 9 | import { cliReleaseDownloadBaseUrl } from "@t3tools/shared/cliRelease"; |
| 10 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 10 | 11 | import * as NetService from "@t3tools/shared/Net"; |
| 11 | 12 | import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 12 | 13 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; |
| ... | ... | @@ -18,7 +19,6 @@ import * as Layer from "effect/Layer"; |
| 18 | 19 | import * as Path from "effect/Path"; |
| 19 | 20 | import * as Schema from "effect/Schema"; |
| 20 | 21 | import * as Scope from "effect/Scope"; |
| 21 | import * as Semaphore from "effect/Semaphore"; | |
| 22 | 22 | import * as Stream from "effect/Stream"; |
| 23 | 23 | import { HttpClient } from "effect/unstable/http"; |
| 24 | 24 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| ... | ... | @@ -1328,7 +1328,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma |
| 1328 | 1328 | ): Effect.fn.Return<SshEnvironmentManagerShape, never, Scope.Scope> { |
| 1329 | 1329 | const managerScope = yield* Scope.Scope; |
| 1330 | 1330 | const tunnels = new Map<string, SshTunnelEntry>(); |
| 1331 | const targetLocks = new Map<string, Semaphore.Semaphore>(); | |
| 1331 | const targetLocks = yield* KeyedLock.make<string>(); | |
| 1332 | 1332 | const authSecrets = new Map<string, string>(); |
| 1333 | 1333 | |
| 1334 | 1334 | // Keep one lock per target so reconnect cannot reuse a server while stop is pending. |
| ... | ... | @@ -1336,12 +1336,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma |
| 1336 | 1336 | key: string, |
| 1337 | 1337 | effect: Effect.Effect<A, E, R>, |
| 1338 | 1338 | ): Effect.fn.Return<A, E, R> { |
| 1339 | let lock = targetLocks.get(key); | |
| 1340 | if (lock === undefined) { | |
| 1341 | lock = Semaphore.makeUnsafe(1); | |
| 1342 | targetLocks.set(key, lock); | |
| 1343 | } | |
| 1344 | return yield* lock.withPermits(1)(effect); | |
| 1339 | return yield* targetLocks.withLock(key, effect); | |
| 1345 | 1340 | }); |
| 1346 | 1341 | |
| 1347 | 1342 | const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* ( |