Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/sim/lib/api-key/byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { organizationBYOKKeys, workspace, workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, notExists } from 'drizzle-orm'
import { LRUCache } from 'lru-cache'
import { isOrganizationBYOKEntitledCached } from '@/lib/api-key/byok-entitlement'
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
import { env } from '@/lib/core/config/env'
Expand All @@ -27,7 +28,13 @@ export interface BYOKKeyResult {

export type BYOKKeyScopeName = 'workspace' | 'organization'

const rotationCounters = new Map<string, number>()
/**
* Bounded so tenant-keyed cursors cannot accumulate for the life of the
* process (one entry per workspace/organization × provider that ever rotated).
* Evicting an idle pool's cursor just restarts its rotation at index 0, which
* the per-instance, approximate-rotation contract already tolerates.
*/
const rotationCounters = new LRUCache<string, number>({ max: 10_000 })

interface EncryptedBYOKKey {
id: string
Expand Down
28 changes: 19 additions & 9 deletions apps/sim/lib/collab-doc/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ function markdownSchema(): Schema {
return cachedSchema
}

let cachedJsdomWindow: import('jsdom').DOMWindow | null = null

/**
* Ensure a DOM exists for the TipTap editor the markdown engine constructs. In a `jsdom`/browser
* environment `window` + `document` already exist and this is a no-op; in a plain Node server it
Expand All @@ -56,20 +58,28 @@ function markdownSchema(): Schema {
* `document`-only guard (plus a sticky flag) skipped this setup — leaving TipTap to throw "there is no
* window object available". Re-checking the globals every call means a partial stub can never wedge it.
* When `window` is missing we install a coherent jsdom window+document pair, overwriting any such stub.
*
* Both the guard and the install go through `globalThis` explicitly, and the jsdom window itself is a
* module-level singleton. The server bundler can give a bundled module a `window` binding that does
* NOT read `globalThis` (the documented reason TipTap/Yjs sit in `serverExternalPackages` — see
* `next.config.ts`); a bare-`window` guard paired with a `globalThis.window` install can therefore
* disagree forever, re-entering the install on every call. Reading and writing the same object makes
* the guard self-consistent, and the singleton caps this module at ONE jsdom window (megabytes each)
* per process even if some runtime still defeats the guard.
*/
function ensureDomForTipTap(): void {
if (typeof window !== 'undefined' && typeof document !== 'undefined') return
// Lazy require so the client bundle never pulls jsdom in. Bind to `jsdomWindow`, NOT `window` — a
// local `const window` would shadow the global and put the `typeof window` guard above in its
// temporal dead zone ("Cannot access 'window' before initialization").
const { JSDOM } = require('jsdom') as typeof import('jsdom')
const { window: jsdomWindow } = new JSDOM('<!doctype html><html><body></body></html>')
if (typeof globalThis.window !== 'undefined' && typeof globalThis.document !== 'undefined') return
if (!cachedJsdomWindow) {
// Lazy require so the client bundle never pulls jsdom in.
const { JSDOM } = require('jsdom') as typeof import('jsdom')
cachedJsdomWindow = new JSDOM('<!doctype html><html><body></body></html>').window
}
// double-cast-allowed: assigning the jsdom shims onto the global needs an
// index-signature view of `globalThis`, whose declared type has none.
const g = globalThis as unknown as Record<string, unknown>
g.window = jsdomWindow
g.document = jsdomWindow.document
g.navigator ??= jsdomWindow.navigator
g.window = cachedJsdomWindow
g.document = cachedJsdomWindow.document
g.navigator ??= cachedJsdomWindow.navigator
}

/** Convert a file's markdown to a fresh collaborative {@link Y.Doc} (cold-start seed). */
Expand Down
34 changes: 32 additions & 2 deletions apps/sim/lib/copilot/request/lifecycle/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ const {
appendEvent,
cleanupAbortMarker,
hasAbortMarker,
registerActiveStream,
releasePendingChatStream,
unregisterActiveStream,
fetchGo,
} = vi.hoisted(() => ({
runCopilotLifecycle: vi.fn(),
Expand All @@ -39,7 +41,9 @@ const {
appendEvent: vi.fn(),
cleanupAbortMarker: vi.fn(),
hasAbortMarker: vi.fn(),
registerActiveStream: vi.fn(),
releasePendingChatStream: vi.fn(),
unregisterActiveStream: vi.fn(),
fetchGo: vi.fn(),
}))

Expand Down Expand Up @@ -77,8 +81,8 @@ vi.mock('@/lib/copilot/request/session', () => ({
cleanupAbortMarker,
hasAbortMarker,
releasePendingChatStream,
registerActiveStream: vi.fn(),
unregisterActiveStream: vi.fn(),
registerActiveStream,
unregisterActiveStream,
startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)),
isExplicitStopReason: vi.fn().mockReturnValue(false),
SSE_RESPONSE_HEADERS: {},
Expand Down Expand Up @@ -325,6 +329,32 @@ describe('createSSEStream terminal error handling', () => {
expect(lifecycleTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/)
})

it('releases the stream registration and pollers when the session reset fails before the lifecycle starts', async () => {
resetBuffer.mockRejectedValue(new Error('redis down'))

const stream = createSSEStream({
requestPayload: { message: 'hello' },
userId: 'user-1',
streamId: 'stream-leak',
executionId: 'exec-leak',
runId: 'run-leak',
chatId: 'chat-leak',
currentChat: null,
isNewChat: false,
message: 'hello',
titleModel: 'gpt-5.4',
requestId: 'req-leak',
orchestrateOptions: {},
})

await expect(drainStream(stream)).rejects.toThrow('redis down')

expect(runCopilotLifecycle).not.toHaveBeenCalled()
expect(registerActiveStream).toHaveBeenCalledWith('stream-leak', expect.any(AbortController))
expect(unregisterActiveStream).toHaveBeenCalledWith('stream-leak')
expect(releasePendingChatStream).toHaveBeenCalledWith('chat-leak', 'stream-leak')
})

it('does not scan manually authored title input against unrelated active secrets', async () => {
runCopilotLifecycle.mockResolvedValue({
success: true,
Expand Down
32 changes: 31 additions & 1 deletion apps/sim/lib/copilot/request/lifecycle/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS

const publisher = new StreamWriter({ streamId, chatId, requestId })

// Declared at function scope (same rationale as `cancelReason` below) so the
// leak backstop in the orchestration's outer finally can always reach them:
// the stream registration above, the abort poller, and the keepalive are
// process-held resources, and a throw that bypasses the inner finally's
// ordered teardown (e.g. `resetBuffer` failing on a Redis blip before the
// lifecycle starts) previously orphaned them — the poller and keepalive
// intervals then ran, and the activeStreams entry sat, for the life of the
// process.
let abortPoller: ReturnType<typeof startAbortPoller> | undefined
let processResourcesReleased = false

// Classify cancel: signal.reason (explicit-stop set) wins, then
// clientDisconnected, else Unknown (latent contract bug — log it).
const recordCancelled = (errorMessage?: string): CopilotRequestCancelReasonValue => {
Expand Down Expand Up @@ -220,7 +231,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
})
}

const abortPoller = startAbortPoller(streamId, abortController, {
abortPoller = startAbortPoller(streamId, abortController, {
requestId,
chatId,
})
Expand Down Expand Up @@ -347,6 +358,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
if (chatId) {
await releasePendingChatStream(chatId, streamId)
}
processResourcesReleased = true
await scheduleBufferCleanup(streamId)
await scheduleFilePreviewSessionCleanup(streamId)
await cleanupAbortMarker(streamId)
Expand All @@ -371,6 +383,24 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
rootError = error
throw error
} finally {
// Leak backstop for throws that bypassed the inner finally's
// ordered teardown (a session reset failing before the lifecycle
// started, or the teardown itself throwing before its release
// lines). Every step is idempotent — clearInterval and
// stopKeepalive no-op when already stopped, unregister is a keyed
// delete, and the chat-stream release is ownership-guarded against
// a successor stream — and none of them throw, so the otel finish
// below always still runs. On the normal path the flag set by the
// ordered teardown skips this entirely.
if (!processResourcesReleased) {
processResourcesReleased = true
clearInterval(abortPoller)
publisher.stopKeepalive()
unregisterActiveStream(streamId)
if (chatId) {
await releasePendingChatStream(chatId, streamId)
}
}
// `finish` is idempotent, so it's safe whether the POST
// handler started the root (and may also call finish on an
// error path before the stream ran) or we did. The cancel
Expand Down
133 changes: 133 additions & 0 deletions apps/sim/lib/execution/payloads/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
cacheLargeValue,
clearLargeValueCacheForTests,
getLargeValueCacheStats,
materializeLargeValueRefSync,
} from '@/lib/execution/payloads/cache'
import {
LARGE_VALUE_REF_VERSION,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'

const MB = 1024 * 1024
const SCOPE = { executionId: 'exec-1' }

function makeRef(id: string, size: number): LargeValueRef {
return {
__simLargeValueRef: true,
version: LARGE_VALUE_REF_VERSION,
id,
kind: 'object',
size,
executionId: 'exec-1',
}
}

describe('large value cache sweep', () => {
beforeEach(() => {
vi.useFakeTimers()
clearLargeValueCacheForTests()
})

afterEach(() => {
clearLargeValueCacheForTests()
vi.useRealTimers()
})

it('drains expired entries without further cache traffic', () => {
expect(
cacheLargeValue('lv_sweep', { data: 'x'.repeat(64) }, 64, { executionId: 'exec-1' })
).toBe(true)
expect(getLargeValueCacheStats()).toEqual({ entries: 1, trackedBytes: 64 })

vi.advanceTimersByTime(16 * 60 * 1000)

expect(getLargeValueCacheStats()).toEqual({ entries: 0, trackedBytes: 0 })
})

it('retires the sweep timer once the cache drains and re-arms on the next insert', () => {
cacheLargeValue('lv_a', { data: 1 }, 8, { executionId: 'exec-1' })
expect(vi.getTimerCount()).toBe(1)

vi.advanceTimersByTime(16 * 60 * 1000)
expect(vi.getTimerCount()).toBe(0)

cacheLargeValue('lv_b', { data: 2 }, 8, { executionId: 'exec-1' })
expect(vi.getTimerCount()).toBe(1)
})

it('keeps unexpired entries readable across sweep ticks', () => {
cacheLargeValue('lv_live', { data: 'live' }, 16, { executionId: 'exec-1' })

vi.advanceTimersByTime(5 * 60 * 1000)

expect(getLargeValueCacheStats()).toEqual({ entries: 1, trackedBytes: 16 })
})
})

describe('large value cache retention policy', () => {
beforeEach(() => {
vi.useFakeTimers()
clearLargeValueCacheForTests()
})

afterEach(() => {
clearLargeValueCacheForTests()
vi.useRealTimers()
})

it('refreshes the idle TTL on every read so in-use values outlive the absolute window', () => {
cacheLargeValue('lv_touchedvalue', { data: 'v' }, 32, SCOPE)

vi.advanceTimersByTime(10 * 60 * 1000)
expect(materializeLargeValueRefSync(makeRef('lv_touchedvalue', 32), SCOPE)).toEqual({
data: 'v',
})

vi.advanceTimersByTime(10 * 60 * 1000)
expect(materializeLargeValueRefSync(makeRef('lv_touchedvalue', 32), SCOPE)).toEqual({
data: 'v',
})

vi.advanceTimersByTime(16 * 60 * 1000)
expect(materializeLargeValueRefSync(makeRef('lv_touchedvalue', 32), SCOPE)).toBeUndefined()
})

it('pressure-evicts the least-recently-read recoverable entry, not the oldest-inserted', () => {
cacheLargeValue('lv_aaaaaaaaaaaa', { name: 'a' }, 120 * MB, SCOPE, { recoverable: true })
cacheLargeValue('lv_bbbbbbbbbbbb', { name: 'b' }, 120 * MB, SCOPE, { recoverable: true })

expect(materializeLargeValueRefSync(makeRef('lv_aaaaaaaaaaaa', 120 * MB), SCOPE)).toEqual({
name: 'a',
})

expect(
cacheLargeValue('lv_cccccccccccc', { name: 'c' }, 60 * MB, SCOPE, { recoverable: true })
).toBe(true)

expect(materializeLargeValueRefSync(makeRef('lv_aaaaaaaaaaaa', 120 * MB), SCOPE)).toEqual({
name: 'a',
})
expect(
materializeLargeValueRefSync(makeRef('lv_bbbbbbbbbbbb', 120 * MB), SCOPE)
).toBeUndefined()
expect(getLargeValueCacheStats()).toEqual({ entries: 2, trackedBytes: 180 * MB })
})

it('never pressure-evicts a sole-copy entry; admission fails instead', () => {
cacheLargeValue('lv_nnnnnnnnnnnn', { name: 'sole-copy' }, 200 * MB, SCOPE)

expect(
cacheLargeValue('lv_rrrrrrrrrrrr', { name: 'r' }, 100 * MB, SCOPE, { recoverable: true })
).toBe(false)

expect(materializeLargeValueRefSync(makeRef('lv_nnnnnnnnnnnn', 200 * MB), SCOPE)).toEqual({
name: 'sole-copy',
})
expect(getLargeValueCacheStats()).toEqual({ entries: 1, trackedBytes: 200 * MB })
})
})
Loading
Loading