Repository navigation
fix(server): host sleep no longer idle-releases provider sessions on wake #26089
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
base: main
Are you sure you want to change the base?
Changes from all commits
1bccf79
7ce33bb
1f2a7d3
084acf2
2fcbdb3
dafde70
a9204eb
1dd7f86
3650558
2f355d6
3f41142
f0f45c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,6 +88,22 @@ const WORKING_RUN_STATUSES: ReadonlySet<string> = new Set([ | |
| "running", | ||
| "waiting", | ||
| ]); | ||
| const AWAKE_SLICE_MS = 60 * 1000; | ||
| const SUSPEND_TOLERANCE_MS = 10 * 1000; | ||
|
|
||
| // Where the timer clock counts host sleep, a timer fires on wake far past due. Waiting in short slices | ||
| // and dropping any slice that overran its tolerance keeps suspended time out of the count, whatever its | ||
| // length. Where both clocks pause, no slice overruns. Untraced: every provider event re-arms the timer. | ||
| const sleepWhileAwake = Effect.fnUntraced(function* (ms: number): Effect.fn.Return<void> { | ||
| let remainingMs = ms; | ||
| while (remainingMs > 0) { | ||
| const sliceMs = Math.min(remainingMs, AWAKE_SLICE_MS); | ||
| const startedAtNanos = yield* Clock.monotonicTimeNanos; | ||
| yield* Effect.sleep(Duration.millis(sliceMs)); | ||
| const elapsedMs = Number((yield* Clock.monotonicTimeNanos) - startedAtNanos) / 1_000_000; | ||
| if (elapsedMs <= sliceMs + SUSPEND_TOLERANCE_MS) remainingMs -= sliceMs; | ||
| } | ||
| }); | ||
|
|
||
| /** Matches planDelegatedCompletionDelivery: true while a delegated task's result still wakes the thread. */ | ||
| export const delegatedTaskWillWake = (records: { | ||
|
|
@@ -291,7 +307,7 @@ interface LiveSessionEntry { | |
| readonly idleGeneration: number; | ||
| readonly busyTurns: ReadonlySet<string>; | ||
| readonly idleFiber: Fiber.Fiber<void, never> | null; | ||
| readonly pinnedSinceMs: number | null; | ||
| readonly pinnedForMs: number | null; | ||
| readonly idleThreadUnloads: ReadonlyMap<ThreadId, IdleThreadUnload>; | ||
| } | ||
|
|
||
|
|
@@ -1365,9 +1381,9 @@ export const layerWithOptions = ( | |
| ), | ||
| ); | ||
| if (hasPendingWork) { | ||
| const now = yield* Clock.currentTimeMillis; | ||
| const pinnedSinceMs = entry.pinnedSinceMs ?? now; | ||
| if (now - pinnedSinceMs < maxIdlePinMs) { | ||
| // Counted in awake idle windows, so host sleep does not spend the pin budget. | ||
| const pinnedForMs = entry.pinnedForMs ?? 0; | ||
| if (pinnedForMs < maxIdlePinMs) { | ||
| const shouldContinuePin = yield* Ref.modify(sessions, (latest) => { | ||
| const latestEntry = latest.get(key); | ||
| if ( | ||
|
|
@@ -1379,23 +1395,23 @@ export const layerWithOptions = ( | |
| return [false, latest] as const; | ||
| } | ||
| const updated = new Map(latest); | ||
| updated.set(key, { ...latestEntry, pinnedSinceMs }); | ||
| updated.set(key, { ...latestEntry, pinnedForMs: pinnedForMs + idleTimeoutMs }); | ||
| return [true, updated] as const; | ||
| }); | ||
| if (!shouldContinuePin) { | ||
| return; | ||
| } | ||
| yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", { | ||
| providerSessionId: input.providerSessionId, | ||
| pinnedForMs: now - pinnedSinceMs, | ||
| pinnedForMs, | ||
| }); | ||
| // Recheck on this fiber; scheduleIdleReleaseInternal would interrupt it and self-deadlock. | ||
| yield* Effect.sleep(Duration.millis(idleTimeoutMs)); | ||
| yield* sleepWhileAwake(idleTimeoutMs); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exclude host sleep from the pending-work pin budget. If a session is already pinned before an overnight sleep, this wait restarts after wake, but the pin check still uses wall-clock
|
||
| return yield* releaseIfStillIdle(input); | ||
| } | ||
| yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", { | ||
| providerSessionId: input.providerSessionId, | ||
| pinnedForMs: now - pinnedSinceMs, | ||
| pinnedForMs, | ||
| }); | ||
| } | ||
| // Closing skips the threads' own idle unloads, so archive delivered children first. | ||
|
|
@@ -1459,7 +1475,7 @@ export const layerWithOptions = ( | |
|
|
||
| yield* cancelIdleFiber(entry.idleFiber); | ||
| const generation = entry.idleGeneration + 1; | ||
| const idleFiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe( | ||
| const idleFiber = yield* sleepWhileAwake(idleTimeoutMs).pipe( | ||
| Effect.andThen(releaseIfStillIdle({ providerSessionId, generation })), | ||
| Effect.forkIn(layerScope), | ||
| ); | ||
|
|
@@ -1473,7 +1489,7 @@ export const layerWithOptions = ( | |
| ...latestEntry, | ||
| idleGeneration: generation, | ||
| idleFiber, | ||
| ...(options?.providerActivity === true ? { pinnedSinceMs: null } : {}), | ||
| ...(options?.providerActivity === true ? { pinnedForMs: null } : {}), | ||
| }); | ||
| return updated; | ||
| }); | ||
|
|
@@ -1704,7 +1720,7 @@ export const layerWithOptions = ( | |
| ...entry, | ||
| busyTurns: new Set(entry.busyTurns).add(turnKey), | ||
| idleFiber: null, | ||
| pinnedSinceMs: null, | ||
| pinnedForMs: null, | ||
| }); | ||
| return [{ idleFiber: entry.idleFiber }, updated] as const; | ||
| }); | ||
|
|
@@ -1812,7 +1828,7 @@ export const layerWithOptions = ( | |
| if (tracked === undefined) return; | ||
| const [threadId, pending] = tracked; | ||
| const generation = pending.generation + 1; | ||
| const fiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe( | ||
| const fiber = yield* sleepWhileAwake(idleTimeoutMs).pipe( | ||
| Effect.andThen(unloadIdleThread({ providerSessionId, threadId, generation })), | ||
| Effect.forkIn(layerScope), | ||
| ); | ||
|
|
@@ -1959,7 +1975,7 @@ export const layerWithOptions = ( | |
| ); | ||
| // Wait outside the lock so the next attach is not blocked. | ||
| if (outcome === "deferred" && input.archiveOnly !== true) { | ||
| yield* Effect.sleep(Duration.millis(idleTimeoutMs)); | ||
| yield* sleepWhileAwake(idleTimeoutMs); | ||
| return yield* unloadIdleThread(input); | ||
| } | ||
| }, | ||
|
|
@@ -2961,7 +2977,7 @@ export const layerWithOptions = ( | |
| idleGeneration: 0, | ||
| busyTurns: new Set(), | ||
| idleFiber: null, | ||
| pinnedSinceMs: null, | ||
| pinnedForMs: null, | ||
| idleThreadUnloads: new Map(), | ||
| }; | ||
| yield* Ref.update(sessions, (current) => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bound the release loop to the scenario's expected deadline instead of advancing the clock until the session closes.
The loop has no upper bound. If session release breaks,
isLivestays true and the test runs until the project timeout. It then reports a timeout instead of a deadline assertion failure. The loop also re-checks state repeatedly, which the testing rules prohibit under "Never wait on sleeps or polling."Pass each scenario's
expectedDeadlineinto the helper. Then replace the open-endedwhilewith a loop that runs at most that many clock adjustments. Assert that the session is still live before each adjustment that precedes the expected deadline, and assert that it is closed at the end. KeepQueue.take(closes)as the finalizer await, as it is now.