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
114 changes: 114 additions & 0 deletions apps/server/__tests__/services/session-create.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
cancelSessionCreation,
resetSessionCreationCancellationState
} from '#~/services/session/creation-cancellation.js'
import { resetSessionCreationLifecycleState, waitForSessionCreation } from '#~/services/session/creation-lifecycle.js'

const mocks = vi.hoisted(() => ({
getWorkspaceFolder: vi.fn(),
Expand Down Expand Up @@ -62,6 +63,7 @@ describe('createSessionWithInitialMessage', () => {
const createSession = vi.fn()
const updateSession = vi.fn()
const updateSessionRuntimeState = vi.fn()
const getSessionRuntimeState = vi.fn()
const getSession = vi.fn()
const getSessionWorkspace = vi.fn()
const saveMessage = vi.fn()
Expand All @@ -74,6 +76,7 @@ describe('createSessionWithInitialMessage', () => {
beforeEach(() => {
vi.clearAllMocks()
resetSessionCreationCancellationState()
resetSessionCreationLifecycleState()

createSession.mockImplementation((title?: string, id?: string) => ({
id: id ?? 'sess-1',
Expand All @@ -84,11 +87,16 @@ describe('createSessionWithInitialMessage', () => {
id,
createdAt: Date.now()
}))
getSessionRuntimeState.mockReturnValue({
runtimeKind: 'interactive',
historySeedPending: false
})
saveMessage.mockReturnValue(true)
vi.mocked(getDb).mockReturnValue({
createSession,
updateSession,
updateSessionRuntimeState,
getSessionRuntimeState,
getSession,
getSessionWorkspace,
saveMessage,
Expand Down Expand Up @@ -158,6 +166,72 @@ describe('createSessionWithInitialMessage', () => {
)
})

it('wakes early websocket waiters only after workspace provisioning completes', async () => {
let sessionPersisted = false
let finishProvisioning: (() => void) | undefined
createSession.mockImplementationOnce((title?: string, id?: string) => {
sessionPersisted = true
return {
id: id ?? 'sess-creation-race',
title,
createdAt: Date.now()
}
})
getSession.mockImplementation((id: string) =>
sessionPersisted
? { id, createdAt: Date.now() }
: undefined
)
mocks.provisionSessionWorkspace.mockImplementationOnce(() =>
new Promise((resolve) => {
finishProvisioning = () =>
resolve({
sessionId: 'sess-creation-race',
workspaceFolder: '/workspace/root'
})
})
)

const waitPromise = waitForSessionCreation('sess-creation-race')
let waiterSettled = false
void waitPromise.then(() => {
waiterSettled = true
})
const creationPromise = createSessionWithInitialMessage({
id: 'sess-creation-race',
shouldStart: false
})

await vi.waitFor(() => {
expect(mocks.provisionSessionWorkspace).toHaveBeenCalledOnce()
})
expect(waiterSettled).toBe(false)

finishProvisioning?.()
await creationPromise
await expect(waitPromise).resolves.toBeUndefined()
})

it('does not discard an existing non-shell session on duplicate creation', async () => {
getSession.mockReturnValue({
id: 'sess-existing',
createdAt: Date.now(),
messageCount: 0,
title: 'Existing session'
})
createSession.mockImplementationOnce(() => {
throw new Error('UNIQUE constraint failed: sessions.id')
})

await expect(createSessionWithInitialMessage({
id: 'sess-existing',
shouldStart: false
})).rejects.toThrow('UNIQUE constraint failed')

expect(mocks.deleteRuntimeSessionStores).not.toHaveBeenCalled()
expect(deleteSession).not.toHaveBeenCalled()
})

it('cancels creation before a pending session is created', async () => {
cancelSessionCreation('sess-cancel-before-create')

Expand Down Expand Up @@ -199,6 +273,46 @@ describe('createSessionWithInitialMessage', () => {
expect(deleteSession).toHaveBeenCalledWith('sess-cancel-during-workspace')
})

it('keeps the first creation cancellable after an overlapping duplicate request fails', async () => {
let rejectProvisioning: ((error: unknown) => void) | undefined
createSession
.mockImplementationOnce((title?: string, id?: string) => ({
id: id ?? 'sess-overlap-cancel',
title,
createdAt: Date.now()
}))
.mockImplementationOnce(() => {
throw new Error('UNIQUE constraint failed: sessions.id')
})
mocks.provisionSessionWorkspace.mockImplementationOnce(async (_sessionId, options) =>
new Promise((_resolve, reject) => {
rejectProvisioning = reject
options.signal?.addEventListener('abort', () => {
reject(options.signal?.reason)
}, { once: true })
})
)

const firstCreation = createSessionWithInitialMessage({
id: 'sess-overlap-cancel',
initialMessage: 'hello'
})
await vi.waitFor(() => {
expect(rejectProvisioning).toBeTypeOf('function')
})

await expect(createSessionWithInitialMessage({
id: 'sess-overlap-cancel',
initialMessage: 'duplicate'
})).rejects.toThrow('UNIQUE constraint failed')

expect(cancelSessionCreation('sess-overlap-cancel')).toBe('active')
await expect(firstCreation).rejects.toMatchObject({
code: 'session_creation_cancelled'
})
expect(deleteSession).toHaveBeenCalledWith('sess-overlap-cancel')
})

it('uses the shared workspace by default when the project config is not set', async () => {
await createSessionWithInitialMessage({
title: 'Demo',
Expand Down
121 changes: 121 additions & 0 deletions apps/server/__tests__/services/session-creation-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { getDb } from '#~/db/index.js'
import {
SessionCreationWaitError,
beginSessionCreation,
resetSessionCreationLifecycleState,
waitForSessionCreation
} from '#~/services/session/creation-lifecycle.js'

vi.mock('#~/db/index.js', () => ({
getDb: vi.fn()
}))

describe('session creation lifecycle', () => {
const getSession = vi.fn()

beforeEach(() => {
vi.useRealTimers()
resetSessionCreationLifecycleState()
vi.clearAllMocks()
getSession.mockReturnValue(undefined)
vi.mocked(getDb).mockReturnValue({ getSession } as any)
})

it('keeps an early waiter pending until HTTP creation completes', async () => {
const waitPromise = waitForSessionCreation('sess-early')
const attempt = beginSessionCreation('sess-early')
let settled = false
void waitPromise.then(
() => {
settled = true
},
() => {
settled = true
}
)

await Promise.resolve()
expect(settled).toBe(false)

attempt.complete()

await expect(waitPromise).resolves.toBeUndefined()
})

it('does not fail waiters while another creation attempt is still active', async () => {
const firstAttempt = beginSessionCreation('sess-overlap')
const secondAttempt = beginSessionCreation('sess-overlap')
const waitPromise = waitForSessionCreation('sess-overlap')
let settled = false
void waitPromise.then(
() => {
settled = true
},
() => {
settled = true
}
)

firstAttempt.fail(new Error('first failed'))
await Promise.resolve()
expect(settled).toBe(false)

secondAttempt.fail(new Error('second failed'))

await expect(waitPromise).rejects.toBeInstanceOf(SessionCreationWaitError)
})

it('resolves all waiters as soon as one overlapping attempt succeeds', async () => {
const firstAttempt = beginSessionCreation('sess-success')
const secondAttempt = beginSessionCreation('sess-success')
const firstWaiter = waitForSessionCreation('sess-success')
const secondWaiter = waitForSessionCreation('sess-success')

firstAttempt.complete()
secondAttempt.fail(new Error('late duplicate failed'))

await expect(Promise.all([firstWaiter, secondWaiter])).resolves.toEqual([undefined, undefined])
})

it('returns immediately for an already-created session', async () => {
getSession.mockReturnValue({ id: 'sess-existing' })

await expect(waitForSessionCreation('sess-existing')).resolves.toBeUndefined()
})

it('times out when neither a session nor a creation attempt appears', async () => {
vi.useFakeTimers()
const waitPromise = waitForSessionCreation('sess-missing', { timeoutMs: 100 })
const rejection = expect(waitPromise).rejects.toMatchObject({
code: 'session_creation_wait_failed',
message: 'Session creation timed out'
})

await vi.advanceTimersByTimeAsync(100)

await rejection
})

it('stops the wait timeout once HTTP creation begins', async () => {
vi.useFakeTimers()
const waitPromise = waitForSessionCreation('sess-slow', { timeoutMs: 100 })
const attempt = beginSessionCreation('sess-slow')
let settled = false
void waitPromise.then(
() => {
settled = true
},
() => {
settled = true
}
)

await vi.advanceTimersByTimeAsync(1_000)
expect(settled).toBe(false)

attempt.complete()
await expect(waitPromise).resolves.toBeUndefined()
})
})
12 changes: 12 additions & 0 deletions apps/server/__tests__/services/session-start.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,18 @@ describe('startAdapterSession', () => {
expect(runtime.session.kill).not.toHaveBeenCalled()
})

it('rejects a missing session without creating database or workspace state', async () => {
currentSession = undefined

await expect(startAdapterSession('sess-missing')).rejects.toThrow(
'Session sess-missing does not exist'
)

expect(createSession).not.toHaveBeenCalled()
expect(mocks.provisionSessionWorkspace).not.toHaveBeenCalled()
expect(mocks.run).not.toHaveBeenCalled()
})

it('deduplicates concurrent start requests for the same session', async () => {
let resolveRun:
| ((value: { session: { emit: ReturnType<typeof vi.fn>; kill: ReturnType<typeof vi.fn> } }) => void)
Expand Down
Loading
Loading