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
21 changes: 19 additions & 2 deletions relay/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export class FleetDirectory extends DurableObject<Env> {
// one PUT, which is one send per socket, against a fleet that has left
// half-dead sockets behind or a secret-holder opening them in a loop. A
// one-operator fleet reaches double digits.
if (this.ctx.getWebSockets('daemon').length >= MAX_DAEMON_SOCKETS) {
if (this.ctx.getWebSockets('daemon').length >= this.maxDaemonSockets()) {
return new Response('{"error":"too many directory sockets"}', {
status: 503,
headers: JSON_NO_STORE,
Expand Down Expand Up @@ -155,7 +155,7 @@ export class FleetDirectory extends DurableObject<Env> {
return stored(key, 200)
}
const count = (have.get(COUNT_KEY) as number | undefined) ?? 0
if (count >= MAX_ENTRIES) return full()
if (count >= this.maxEntries()) return full()
const version = (have.get(VERSION_KEY) as number | undefined) ?? 0
// One multi-key put: the blob, the count and the version land together or
// not at all.
Expand Down Expand Up @@ -327,6 +327,23 @@ export class FleetDirectory extends DurableObject<Env> {
// Already closing.
}
}

/**
* The cap, read the way the hub reads its deadlines: the binding when there
* is one, the constant when there is not, and the constant again for a
* binding that is not a usable number. Nothing in production binds it; see
* DIRECTORY_MAX_ENTRIES in src/index.ts for why the seam exists at all.
*/
private maxEntries(): number {
const n = Number(this.env.DIRECTORY_MAX_ENTRIES ?? MAX_ENTRIES)
return Number.isFinite(n) && n > 0 ? n : MAX_ENTRIES
}

/** The socket ceiling, read exactly as the entry cap above is. */
private maxDaemonSockets(): number {
const n = Number(this.env.DIRECTORY_MAX_DAEMON_SOCKETS ?? MAX_DAEMON_SOCKETS)
return Number.isFinite(n) && n > 0 ? n : MAX_DAEMON_SOCKETS
}
}

/** The storage prefix for blobs; the rest of the key is the digest. */
Expand Down
17 changes: 17 additions & 0 deletions relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ export interface Env {
* closes it, in ms — the same test seam. Unset in production, where the hub
* defaults to five minutes (src/hub.ts, CLIENT_IDLE_MS). */
CLIENT_IDLE_TIMEOUT_MS?: string | number
/** How many entries one fleet directory will hold — the same test seam
* (vitest binds a small number). Unset in production, where it is 512
* (src/directory.ts, MAX_ENTRIES), and the reasoning for that number is
* written down beside it.
*
* It is a seam because the cap is only reachable by filling it, and filling
* it is 512 sequential round trips through a Durable Object — five seconds
* of a runner's time on a good day, and over vitest's own deadline on a
* loaded one. Binding it is the difference between a test about the cap and
* a bet on how fast the machine is. */
DIRECTORY_MAX_ENTRIES?: string | number
/** How many push sockets one fleet directory will hold — the same test seam
* for the same reason. Unset in production, where it is 256
* (src/directory.ts, MAX_DAEMON_SOCKETS). Reaching that cap means opening
* 256 WebSockets one at a time and holding them all open, which is the other
* test in this file that timed out on CI and passed on a laptop. */
DIRECTORY_MAX_DAEMON_SOCKETS?: string | number
/** The version of the flue that deployed this Worker, stamped by the
* deploy as a plain-text binding (internal/relaydeploy, VersionVar) and
* reported on /api/health. It is how a daemon sees that this relay is
Expand Down
30 changes: 26 additions & 4 deletions relay/test/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,33 @@ import { BASE, Leg, machineId, sleep, TEST_SECRET, within } from './harness'
/** Mirrors MAX_BLOB_BYTES in src/directory.ts. */
const MAX_BLOB_BYTES = 4096

/** Mirrors MAX_ENTRIES in src/directory.ts. */
const MAX_ENTRIES = 512
/**
* Mirrors DIRECTORY_MAX_ENTRIES in vitest.config.ts, *not* MAX_ENTRIES in
* src/directory.ts — the cap is a test seam, and this is the bound half of it.
*
* Production is 512, and the arithmetic that picks it is written down beside
* the constant. It is not the number to test against: the only way to test a
* cap is to reach it, and reaching 512 is 512 sequential round trips through a
* Durable Object — around five seconds on a quiet machine and past vitest's
* own five-second deadline on a loaded one, which is how these tests came to
* fail in CI and pass on a laptop. This reaches the same branch in a fraction
* of that; what is under test is the refusal, not the size of the number.
*
* It has headroom on purpose. The shared directory — the one `SELF` routes to,
* which nearly every other test in this file writes into — holds ten entries
* by the end of the run, and a bound it could reach would start refusing PUTs
* in tests that are not about the cap at all. That failure reads as "the push
* never came", nowhere near the number that caused it.
*/
const MAX_ENTRIES = 64

/** Mirrors MAX_DAEMON_SOCKETS in src/directory.ts. */
const MAX_DAEMON_SOCKETS = 256
/**
* Mirrors DIRECTORY_MAX_DAEMON_SOCKETS in vitest.config.ts, for the reason
* MAX_ENTRIES above is bound: production is 256, and reaching it means opening
* 256 WebSockets one at a time and holding all of them open. What is under
* test is the refusal at the ceiling, not where the ceiling is.
*/
const MAX_DAEMON_SOCKETS = 8

const DIRECTORY = `${BASE}/directory`

Expand Down
11 changes: 11 additions & 0 deletions relay/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ export default defineConfig({
HANDSHAKE_TIMEOUT_MS: 600_000,
CLIENT_IDLE_TIMEOUT_MS: 600_000,
PAIR_TIMEOUT_MS: 250,
// The directory's entry cap, bound small for the same reason the
// deadlines above are bound at all: the only way to test a cap is to
// reach it, and reaching the production 512 is 512 sequential round
// trips through a Durable Object — five seconds on a quiet machine
// and past vitest's own deadline on a loaded one. test/directory.ts
// mirrors this number; production binds nothing and gets 512.
DIRECTORY_MAX_ENTRIES: 64,
// And the socket ceiling, bound small for the same reason: reaching
// 256 means opening 256 WebSockets one at a time and holding every
// one of them open. test/directory.ts mirrors this number too.
DIRECTORY_MAX_DAEMON_SOCKETS: 8,
DAEMON_SECRET: 'test-secret',
// The deploy stamps this (internal/relaydeploy, VersionVar); binding
// it here is what lets the health test pin the passthrough.
Expand Down
187 changes: 187 additions & 0 deletions web/src/components/new-session-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'

import type { NewSessionRequest } from '@/sessions/new-session'
import { NewSessionDialog, type NewSessionDialogProps } from './new-session-dialog'

const MACHINES = [
{ id: 'local', name: 'mesa.local' },
{ id: 'attic-pi', name: 'Attic Pi' },
]

function show(over: Partial<NewSessionDialogProps> = {}) {
const props: NewSessionDialogProps = {
open: true,
initial: {},
machines: MACHINES,
known: [],
onSubmit: vi.fn(),
onClose: vi.fn(),
...over,
}
const view = render(<NewSessionDialog {...props} />)
const submitted = () => (props.onSubmit as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as
| NewSessionRequest
| undefined
return { ...view, props, submitted, rerender: view.rerender }
}

const start = () => userEvent.click(screen.getByRole('button', { name: 'Start session' }))

describe('NewSessionDialog', () => {
it('submits the ridden machine and nothing else when nothing is typed', async () => {
// The bar this had to clear to be allowed in front of a one-click button:
// opening it and pressing Start must be the old behaviour exactly.
const { submitted } = show({ initial: { machineId: 'local' } })

await start()

expect(submitted()).toEqual({ machineId: 'local', cwd: '', name: '', tags: [] })
})

it('carries a name, a directory and tags', async () => {
const user = userEvent.setup()
const { submitted } = show({ initial: { machineId: 'local' } })

await user.type(screen.getByLabelText('Name'), 'deploy')
await user.type(screen.getByLabelText('Directory'), '/srv/app')
await user.type(screen.getByLabelText('Tags'), 'ops{Enter}api{Enter}')
await start()

expect(submitted()).toEqual({
machineId: 'local',
cwd: '/srv/app',
name: 'deploy',
tags: ['ops', 'api'],
})
})

it('counts a tag typed but never entered', async () => {
// Somebody who typed a tag and reached straight for Start is done, and a
// dialog that threw the keystroke away would be disagreeing with them in
// silence — the chips are gone before anyone can read what was sent.
const user = userEvent.setup()
const { submitted } = show()

await user.type(screen.getByLabelText('Tags'), 'staging')
await start()

expect(submitted()?.tags).toEqual(['staging'])
})

it('does not start a session on the Enter that finishes a tag', async () => {
// The tag field sits inside a real form, so its own Enter has to be
// stopped: a set the reader was still assembling is not an answer.
const user = userEvent.setup()
const { props } = show()

await user.type(screen.getByLabelText('Tags'), 'ops{Enter}')

expect(props.onSubmit).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Remove ops' })).toBeTruthy()
})

it('submits on Enter from the name field, like every other form', async () => {
const user = userEvent.setup()
const { submitted } = show({ initial: { machineId: 'attic-pi' } })

await user.type(screen.getByLabelText('Name'), 'quick{Enter}')

expect(submitted()).toEqual({ machineId: 'attic-pi', cwd: '', name: 'quick', tags: [] })
})

it('opens on what the press implied, and lets it be edited', async () => {
const user = userEvent.setup()
const { submitted } = show({
initial: { machineId: 'attic-pi', cwd: '/srv', tags: ['api'] },
})

expect(screen.getByLabelText('Directory')).toHaveProperty('value', '/srv')

// A prefill and not a decision: the chip is there to be taken off again.
await user.click(screen.getByRole('button', { name: 'Remove api' }))
await start()

expect(submitted()).toEqual({ machineId: 'attic-pi', cwd: '/srv', name: '', tags: [] })
})

it('offers the fleet’s own tags, minus the ones already chosen', async () => {
const user = userEvent.setup()
const { submitted } = show({ known: ['api', 'ops'], initial: { tags: ['api'] } })

expect(screen.queryByRole('button', { name: 'Add api' })).toBeNull()
await user.click(screen.getByRole('button', { name: 'Add ops' }))
await start()

expect(submitted()?.tags).toEqual(['api', 'ops'])
})

it('puts the chosen tags under their field, not above its heading', async () => {
// Leading chips are right for a dialog whose whole subject is tags. Here
// they read as a stray line belonging to the field before them: "No tags
// yet." landed between the Directory input and a heading called Tags.
const user = userEvent.setup()
show()

await user.type(screen.getByLabelText('Tags'), 'ops{Enter}')

const field = screen.getByLabelText('Tags')
const chip = screen.getByRole('button', { name: 'Remove ops' })
expect(field.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})

it('asks which machine only when there is a choice to make', () => {
const { unmount } = show({ machines: [MACHINES[0]!] })
expect(screen.queryByRole('combobox', { name: 'Machine' })).toBeNull()
unmount()

show()
expect(screen.getByRole('combobox', { name: 'Machine' })).toBeTruthy()
})

it('falls back to the first machine when the press named one that has gone', async () => {
// A heading for a machine that dropped between render and click. Without
// the fallback the trigger renders blank over a form pointing at an id no
// option carries.
const { submitted } = show({ initial: { machineId: 'vanished' } })

await start()

expect(submitted()?.machineId).toBe('local')
})

it('takes the machine the fleet has not named yet, once it names it', async () => {
// The terminal screen subscribes to the fleet and the first delivery can
// land after this has rendered. A machine chosen once, at mount, would
// leave the picker empty for good.
const { rerender, submitted, props } = show({ machines: [], initial: { machineId: 'local' } })

expect(screen.getByText(/no machine is reachable/i)).toBeTruthy()

rerender(<NewSessionDialog {...props} machines={MACHINES} />)
await start()

expect(submitted()?.machineId).toBe('local')
})

it('refuses in words when no machine is reachable', async () => {
const { props } = show({ machines: [] })

expect(screen.getByRole('button', { name: 'Start session' })).toHaveProperty('disabled', true)

await start()

expect(props.onSubmit).not.toHaveBeenCalled()
})

it('closes itself after a submit, and on Cancel', async () => {
const { props } = show({ initial: { machineId: 'local' } })

await start()
expect(props.onClose).toHaveBeenCalledTimes(1)

await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(props.onClose).toHaveBeenCalledTimes(2)
})
})
Loading