-
Notifications
You must be signed in to change notification settings - Fork 58
fix(cli): negotiate relayfile control-plane API before v3 calls #1324
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { type ChildProcess, spawn } from 'node:child_process'; | ||
| import { mkdtempSync, rmSync } from 'node:fs'; | ||
| import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; | ||
| import { createServer, type RequestListener } from 'node:http'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
|
|
||
|
|
@@ -8,6 +9,184 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | |
| import { MIN_RELAYFILE_VERSION, assertRelayfileVersion, defaultRelayfileBridge } from './integration.js'; | ||
| import { RelayfileControlPlaneClient } from '@relayfile/client'; | ||
|
|
||
| let socketSequence = 0; | ||
|
|
||
| async function withControlPlaneServer( | ||
| handler: RequestListener, | ||
| run: (socketPath: string) => Promise<void> | ||
| ): Promise<void> { | ||
| const socketPath = join(tmpdir(), `rf-negotiation-${process.pid}-${++socketSequence}.sock`); | ||
| rmSync(socketPath, { force: true }); | ||
| const server = createServer(handler); | ||
|
|
||
| await new Promise<void>((resolve, reject) => { | ||
| server.once('error', reject); | ||
| server.listen(socketPath, resolve); | ||
| }); | ||
|
|
||
| try { | ||
| await run(socketPath); | ||
| } finally { | ||
| await new Promise<void>((resolve, reject) => { | ||
| server.close((error) => (error ? reject(error) : resolve())); | ||
| }); | ||
| rmSync(socketPath, { force: true }); | ||
| } | ||
| } | ||
|
|
||
| function writeJson(response: Parameters<RequestListener>[1], status: number, body: unknown): void { | ||
| response.writeHead(status, { 'Content-Type': 'application/json' }); | ||
| response.end(JSON.stringify(body)); | ||
| } | ||
|
|
||
| async function withFakeRelayfileBinary( | ||
| version: string, | ||
| run: (binary: string) => Promise<void> | ||
| ): Promise<void> { | ||
| const fixtureDir = mkdtempSync(join(tmpdir(), 'rf-negotiation-bin-')); | ||
| const binary = join(fixtureDir, 'relayfile'); | ||
| writeFileSync( | ||
| binary, | ||
| `#!/usr/bin/env node | ||
| const { rmSync } = require('node:fs'); | ||
| const { createServer } = require('node:http'); | ||
|
|
||
| const args = process.argv.slice(2); | ||
| if (args[0] === '--version') { | ||
| process.stdout.write(${JSON.stringify(`${version}\n`)}); | ||
| process.exit(0); | ||
| } | ||
| if (args[0] !== 'control-plane' || args[1] !== 'serve') process.exit(2); | ||
| const socketIndex = args.indexOf('--sock'); | ||
| const socketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined; | ||
| if (!socketPath) process.exit(2); | ||
|
|
||
| rmSync(socketPath, { force: true }); | ||
| let closeTimer; | ||
| const server = createServer((request, response) => { | ||
| if (request.method === 'GET' && request.url === '/v1/hello') { | ||
| response.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| response.end(JSON.stringify({ | ||
| daemonVersion: ${JSON.stringify(version)}, | ||
| apiVersion: 3, | ||
| supportedApiVersions: [1, 2, 3], | ||
| })); | ||
| } else { | ||
| response.writeHead(404, { 'Content-Type': 'application/json' }); | ||
| response.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } })); | ||
| } | ||
| clearTimeout(closeTimer); | ||
| closeTimer = setTimeout(() => server.close(() => process.exit(0)), 500); | ||
| }); | ||
| server.listen(socketPath); | ||
| setTimeout(() => server.close(() => process.exit(0)), 5000); | ||
| process.on('SIGTERM', () => server.close(() => process.exit(0))); | ||
| `, | ||
| 'utf8' | ||
| ); | ||
| chmodSync(binary, 0o755); | ||
|
|
||
| try { | ||
| await run(binary); | ||
| } finally { | ||
| rmSync(fixtureDir, { recursive: true, force: true }); | ||
| } | ||
| } | ||
|
|
||
| async function withExternalControlPlaneDaemon( | ||
| daemonVersion: string, | ||
| apiVersion: number, | ||
| supportedApiVersions: number[], | ||
| run: (socketPath: string) => Promise<void> | ||
| ): Promise<void> { | ||
| const socketPath = join(tmpdir(), `rf-external-${process.pid}-${++socketSequence}.sock`); | ||
| rmSync(socketPath, { force: true }); | ||
| const child = spawn( | ||
| process.execPath, | ||
| [ | ||
| '-e', | ||
| `const { rmSync } = require('node:fs'); | ||
| const { createServer } = require('node:http'); | ||
| const [socketPath, daemonVersion, apiVersionRaw, supportedRaw] = process.argv.slice(1); | ||
| const apiVersion = Number(apiVersionRaw); | ||
| const supportedApiVersions = JSON.parse(supportedRaw); | ||
| rmSync(socketPath, { force: true }); | ||
| const server = createServer((request, response) => { | ||
| if (request.method === 'GET' && request.url === '/v1/hello') { | ||
| response.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| response.end(JSON.stringify({ daemonVersion, apiVersion, supportedApiVersions })); | ||
| return; | ||
| } | ||
| response.writeHead(404, { 'Content-Type': 'application/json' }); | ||
| response.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } })); | ||
| }); | ||
| const stop = () => server.close(() => { | ||
| rmSync(socketPath, { force: true }); | ||
| process.exit(0); | ||
| }); | ||
| process.on('SIGTERM', stop); | ||
| server.listen(socketPath, () => process.stdout.write('ready\\n')); | ||
| `, | ||
| socketPath, | ||
| daemonVersion, | ||
| String(apiVersion), | ||
| JSON.stringify(supportedApiVersions), | ||
| ], | ||
| { stdio: ['ignore', 'pipe', 'pipe'] } | ||
| ); | ||
|
|
||
| await new Promise<void>((resolve, reject) => { | ||
| let stderr = ''; | ||
| const fail = (message: string) => { | ||
| cleanup(); | ||
| reject(new Error(message)); | ||
| }; | ||
| const onData = (chunk: Buffer) => { | ||
| if (String(chunk).includes('ready')) { | ||
| cleanup(); | ||
| resolve(); | ||
| } | ||
| }; | ||
| const onError = (error: Error) => fail(`failed to start fake relayfile daemon: ${error.message}`); | ||
| const onExit = (code: number | null, signal: NodeJS.Signals | null) => | ||
| fail(`fake relayfile daemon exited before ready (code ${code}, signal ${signal}): ${stderr}`); | ||
| const cleanup = () => { | ||
| child.stdout?.off('data', onData); | ||
| child.stderr?.off('data', onStderr); | ||
| child.off('error', onError); | ||
| child.off('exit', onExit); | ||
| }; | ||
| const onStderr = (chunk: Buffer) => { | ||
| stderr += String(chunk); | ||
| }; | ||
| child.stdout?.on('data', onData); | ||
| child.stderr?.on('data', onStderr); | ||
| child.once('error', onError); | ||
| child.once('exit', onExit); | ||
| }); | ||
|
Comment on lines
+138
to
+166
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. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Bound the fake daemon readiness wait. If the child stays alive without emitting 🧰 Tools🪛 ast-grep (0.44.1)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||
|
|
||
| try { | ||
| await run(socketPath); | ||
| } finally { | ||
| await new Promise<void>((resolve) => { | ||
| if (child.exitCode !== null || child.signalCode !== null) { | ||
| resolve(); | ||
| return; | ||
| } | ||
| const timeout = setTimeout(() => { | ||
| child.kill('SIGKILL'); | ||
| resolve(); | ||
| }, 2000); | ||
| child.once('exit', () => { | ||
| clearTimeout(timeout); | ||
| resolve(); | ||
| }); | ||
| child.kill('SIGTERM'); | ||
| }); | ||
| rmSync(socketPath, { force: true }); | ||
| } | ||
| } | ||
|
|
||
| // ──────────────────────────────────────────────────────────────────────────── | ||
| // Pure version-gate unit tests — always run, no daemon required. These lock the | ||
| // daemon-version compat check (`/v1/hello` → daemonVersion) that turns "daemon | ||
|
|
@@ -42,6 +221,130 @@ describe('assertRelayfileVersion', () => { | |
| }); | ||
| }); | ||
|
|
||
| describe('relayfile control-plane hello negotiation', () => { | ||
| it('discovers supported APIs without a version header, then uses v3 for normal requests', async () => { | ||
| const requests: Array<{ method?: string; path?: string; version?: string }> = []; | ||
|
|
||
| await withControlPlaneServer( | ||
| (request, response) => { | ||
| requests.push({ | ||
| method: request.method, | ||
| path: request.url, | ||
| version: request.headers['x-relayfile-api-version'] as string | undefined, | ||
| }); | ||
|
|
||
| if (request.method === 'GET' && request.url === '/v1/hello') { | ||
| writeJson(response, 200, { | ||
| daemonVersion: '0.10.27', | ||
| apiVersion: 3, | ||
| supportedApiVersions: [1, 2, 3], | ||
| }); | ||
| return; | ||
| } | ||
| if (request.method === 'GET' && request.url?.startsWith('/v1/integrations/provider-status?')) { | ||
| writeJson(response, 200, { provider: 'slack', state: 'connected' }); | ||
| return; | ||
| } | ||
| writeJson(response, 404, { error: { code: 'NOT_FOUND', message: 'not found' } }); | ||
| }, | ||
| async (socketPath) => { | ||
| const bridge = defaultRelayfileBridge({ socketPath, autoStart: false }); | ||
| await bridge.ensureCompatible(); | ||
| await expect(bridge.isConnected('slack')).resolves.toBe(true); | ||
| } | ||
| ); | ||
|
|
||
| const helloRequests = requests.filter(({ path }) => path === '/v1/hello'); | ||
| expect(helloRequests.length).toBeGreaterThan(0); | ||
| expect(helloRequests.every(({ method, version }) => method === 'GET' && version === undefined)).toBe( | ||
| true | ||
| ); | ||
| expect(requests.find(({ path }) => path?.startsWith('/v1/integrations/provider-status?'))?.version).toBe( | ||
| '3' | ||
| ); | ||
| }); | ||
|
|
||
| it('fails legacy v1 discovery once, before sending a versioned operation', async () => { | ||
| const requests: Array<{ method?: string; path?: string; version?: string }> = []; | ||
|
|
||
| await withControlPlaneServer( | ||
| (request, response) => { | ||
| requests.push({ | ||
| method: request.method, | ||
| path: request.url, | ||
| version: request.headers['x-relayfile-api-version'] as string | undefined, | ||
| }); | ||
| writeJson(response, 200, { | ||
| daemonVersion: '0.10.19', | ||
| apiVersion: 1, | ||
| supportedApiVersions: [1], | ||
| }); | ||
| }, | ||
| async (socketPath) => { | ||
| const bridge = defaultRelayfileBridge({ socketPath, autoStart: false }); | ||
| await expect(bridge.ensureCompatible()).rejects.toMatchObject({ | ||
| code: 'VERSION_INCOMPATIBLE', | ||
| message: expect.stringMatching( | ||
| /relayfile daemon 0\.10\.19.*speaks API v1.*supports v1.*requires API v3.*npm install -g relayfile@latest.*control-plane serve/s | ||
| ), | ||
| }); | ||
| } | ||
| ); | ||
|
|
||
| expect(requests).toEqual([{ method: 'GET', path: '/v1/hello', version: undefined }]); | ||
| }); | ||
|
|
||
| it('retries a transient missing socket while an auto-started daemon becomes ready', async () => { | ||
| await withFakeRelayfileBinary('0.10.27', async (binary) => { | ||
| const socketPath = join(tmpdir(), `rf-transient-${process.pid}-${++socketSequence}.sock`); | ||
| rmSync(socketPath, { force: true }); | ||
| const bridge = defaultRelayfileBridge({ | ||
| socketPath, | ||
| binary, | ||
| autoStart: true, | ||
| startTimeoutMs: 2000, | ||
| requestTimeoutMs: 250, | ||
| }); | ||
|
|
||
| await expect(bridge.ensureCompatible()).resolves.toBeUndefined(); | ||
| rmSync(socketPath, { force: true }); | ||
| }); | ||
| }); | ||
|
Comment on lines
+297
to
+312
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Make the missing-socket retry deterministic. The fixture may create its socket before the first request, allowing this test to pass without exercising a retry. Delay socket creation beyond the initial request attempt while keeping it within 🧰 Tools🪛 ast-grep (0.44.1)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||
|
|
||
| it('replaces a stale v1 daemon only when the installed binary can serve v3', async () => { | ||
| await withFakeRelayfileBinary('0.10.27', async (binary) => { | ||
| await withExternalControlPlaneDaemon('0.10.19', 1, [1], async (socketPath) => { | ||
| const bridge = defaultRelayfileBridge({ | ||
| socketPath, | ||
| binary, | ||
| autoStart: true, | ||
| startTimeoutMs: 2000, | ||
| requestTimeoutMs: 1000, | ||
| }); | ||
| await expect(bridge.ensureCompatible()).resolves.toBeUndefined(); | ||
| }); | ||
|
Comment on lines
+314
to
+325
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. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Assert the stale daemon’s actual process state. These results do not prove the lifecycle contract: an unlink-only replacement or premature termination of the retained daemon could still pass. Expose the child to the callback, then assert it exited after replacement and remains alive after incompatibility. Also applies to: 329-345 🧰 Tools🪛 ast-grep (0.44.1)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||
| }); | ||
| }); | ||
|
|
||
| it('leaves a stale v1 daemon in place when the installed binary is also too old', async () => { | ||
| await withFakeRelayfileBinary('0.10.19', async (binary) => { | ||
| await withExternalControlPlaneDaemon('0.10.19', 1, [1], async (socketPath) => { | ||
| const bridge = defaultRelayfileBridge({ | ||
| socketPath, | ||
| binary, | ||
| autoStart: true, | ||
| startTimeoutMs: 2000, | ||
| requestTimeoutMs: 1000, | ||
| }); | ||
| await expect(bridge.ensureCompatible()).rejects.toMatchObject({ | ||
| code: 'VERSION_INCOMPATIBLE', | ||
| message: expect.stringContaining('Installed relayfile binary: 0.10.19'), | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| // ──────────────────────────────────────────────────────────────────────────── | ||
| // Real-daemon contract tests — boot `relayfile control-plane serve` and exercise | ||
| // the ACTUAL bridge over the unix socket (no spawn-per-op, no stdout parsing). | ||
|
|
||
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.
The helper functions
withFakeRelayfileBinary(lines 42-94) andwithExternalControlPlaneDaemon(lines 96-188) embed significant amounts of JavaScript code as multi-line strings. This practice can make the code harder to read, debug, and maintain, as it lacks syntax highlighting and linting within the string literal.For better maintainability, consider extracting these scripts into separate fixture files (e.g., in a
__fixtures__directory) and loading them withfs.readFileSync. This would improve code organization and developer experience.