Skip to content
Open
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,18 @@ jobs:
run: |
chmod +x cli/bin/freebuff
cli/bin/freebuff --version
BUILD_INFO="$(cli/bin/freebuff --print-build-info)" bun -e '
const actual = JSON.parse(process.env.BUILD_INFO);
const expected = {
schemaVersion: 1,
product: "freebuff",
version: "0.0.0-ci",
target: "linux-x64",
};
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Unexpected binary identity: ${JSON.stringify(actual)}`,
);
}
'
bun cli/scripts/smoke-binary.ts cli/bin/freebuff
140 changes: 138 additions & 2 deletions cli/release-core/launcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ function createLauncher(productConfig) {
// Tests only. os.homedir() ignores $HOME under `bun test`, so pointing HOME
// at a temp dir is not enough to keep a test off the real ~/.config.
configDir: configDirOverride = null,
// Tests only. Production keeps the 10s bound for slow Windows startup.
buildIdentityTimeoutMs = 10000,
} = productConfig

/**
Expand Down Expand Up @@ -82,6 +84,10 @@ function createLauncher(productConfig) {
/** Bytes of the binary's stderr kept for the crash report. */
const STDERR_TAIL_BYTES = 8192

/** Versioned contract emitted by compiled binaries via --print-build-info. */
const BUILD_IDENTITY_SCHEMA_VERSION = 1
const BUILD_IDENTITY_OUTPUT_BYTES = 4096

function getUnsignedExitCode(code) {
return code != null && code < 0 ? code >>> 0 : code
}
Expand Down Expand Up @@ -741,6 +747,120 @@ function createLauncher(productConfig) {
}
}

function readStagedBinaryIdentity(binaryPath) {
return new Promise((resolve, reject) => {
const child = spawn(binaryPath, ['--print-build-info'], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
})

let stdout = ''
let stderr = ''
let outputBytes = 0
let settled = false

const settle = (error, identity = null) => {
if (settled) return
settled = true
clearTimeout(timer)
if (error) reject(error)
else resolve(identity)
}

const append = (stream) => (chunk) => {
outputBytes += chunk.length
if (outputBytes > BUILD_IDENTITY_OUTPUT_BYTES) {
const error = new Error(
`Downloaded artifact produced more than ${BUILD_IDENTITY_OUTPUT_BYTES} bytes while reporting its identity`,
)
error.code = 'ARTIFACT_IDENTITY_INVALID'
child.kill('SIGKILL')
settle(error)
return
}
if (stream === 'stdout') stdout += chunk.toString('utf8')
else stderr += chunk.toString('utf8')
}

child.stdout?.on('data', append('stdout'))
child.stderr?.on('data', append('stderr'))

const timer = setTimeout(() => {
const error = new Error(
`Downloaded artifact did not report its identity within ${buildIdentityTimeoutMs}ms`,
)
error.code = 'ARTIFACT_IDENTITY_TIMEOUT'
child.kill('SIGKILL')
settle(error)
}, buildIdentityTimeoutMs)

child.once('error', (cause) => {
const error = new Error(
`Could not execute downloaded artifact: ${cause.message}`,
{ cause },
)
error.code = 'ARTIFACT_IDENTITY_EXEC_FAILED'
settle(error)
})
child.once('close', (code, signal) => {
if (code !== 0) {
const detail = stderr.trim().slice(0, 512)
const error = new Error(
`Downloaded artifact identity command exited with ${
signal ? `signal ${signal}` : `code ${code}`
}${detail ? `: ${detail}` : ''}`,
)
error.code = 'ARTIFACT_IDENTITY_EXEC_FAILED'
settle(error)
return
}

let identity
try {
identity = JSON.parse(stdout.trim())
} catch {
const error = new Error(
'Downloaded artifact returned malformed build identity JSON',
)
error.code = 'ARTIFACT_IDENTITY_INVALID'
settle(error)
return
}
settle(null, identity)
})
})
}

async function validateStagedBinary({ tempBinaryPath, version, targetKey }) {
const identity = await readStagedBinaryIdentity(tempBinaryPath)
const expected = {
schemaVersion: BUILD_IDENTITY_SCHEMA_VERSION,
product: packageName,
version,
target: targetKey,
}

const matches =
identity &&
!Array.isArray(identity) &&
identity.schemaVersion === expected.schemaVersion &&
identity.product === expected.product &&
identity.version === expected.version &&
identity.target === expected.target

if (!matches) {
const error = new Error(
`Downloaded artifact identity mismatch: expected ${JSON.stringify(
expected,
)}, received ${JSON.stringify(identity)}`,
)
error.code = 'ARTIFACT_IDENTITY_MISMATCH'
error.retryable = false
throw error
}
}

async function stageBinary(
version,
targetKey = getDownloadTargetKey(),
Expand Down Expand Up @@ -775,11 +895,22 @@ function createLauncher(productConfig) {
if (process.platform !== 'win32') {
fs.chmodSync(tempBinaryPath, 0o755)
}
await validateStagedBinary({ tempBinaryPath, version, targetKey })
} catch (error) {
try {
fs.rmSync(CONFIG.tempDownloadDir, { recursive: true, force: true })
} catch {
// Preserve the original chmod error.
// Preserve the original staging error.
}
if (
typeof error?.code === 'string' &&
error.code.startsWith('ARTIFACT_IDENTITY_')
) {
trackUpdateFailed(error.message, version, {
stage: 'artifact_validation',
errorCode: error.code,
target: targetKey,
})
}
throw error
}
Expand Down Expand Up @@ -977,7 +1108,10 @@ function createLauncher(productConfig) {
// relaunch's download for the shared temp directory (prepareTempDownloadDir
// rmSyncs it) and then spend six seconds SIGKILLing a process that has
// already exited.
if (runningProcess.exitCode !== null || runningProcess.signalCode !== null) {
if (
runningProcess.exitCode !== null ||
runningProcess.signalCode !== null
) {
return
}

Expand Down Expand Up @@ -1459,6 +1593,8 @@ function createLauncher(productConfig) {
getCurrentVersion,
getMetadataVersion,
getRequiredWrapperVersion,
readStagedBinaryIdentity,
validateStagedBinary,
ensureBinaryReady,
isTargetAllowedForThisMachine,
CONFIG,
Expand Down
1 change: 1 addition & 0 deletions cli/scripts/build-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ async function main() {
const defineFlags = [
['process.env.NODE_ENV', '"production"'],
['process.env.CODEBUFF_IS_BINARY', '"true"'],
['process.env.CODEBUFF_CLI_BINARY_NAME', JSON.stringify(binaryName)],
['process.env.CODEBUFF_CLI_VERSION', `"${version}"`],
['process.env.CODEBUFF_CLI_TARGET', `"${getCliTargetLabel(targetInfo)}"`],
['process.env.FREEBUFF_MODE', `"${process.env.FREEBUFF_MODE ?? 'false'}"`],
Expand Down
61 changes: 61 additions & 0 deletions cli/src/__tests__/build-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from 'bun:test'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'

import {
BUILD_IDENTITY_SCHEMA_VERSION,
createBuildIdentity,
} from '../build-identity'

describe('compiled binary identity', () => {
test('uses a versioned, machine-readable contract', () => {
expect(
createBuildIdentity({
product: 'freebuff',
version: '1.2.3',
target: 'win32-x64',
}),
).toEqual({
schemaVersion: BUILD_IDENTITY_SCHEMA_VERSION,
product: 'freebuff',
version: '1.2.3',
target: 'win32-x64',
})
})

test('preserves baseline target labels', () => {
expect(
createBuildIdentity({
product: 'freebuff',
version: '1.2.3',
target: 'win32-x64-baseline',
}).target,
).toBe('win32-x64-baseline')
})

test('entrypoint reports identity without loading the UI', () => {
const entryPath = fileURLToPath(new URL('../entry.ts', import.meta.url))
const result = spawnSync(
process.execPath,
[entryPath, '--print-build-info'],
{
encoding: 'utf8',
env: {
...process.env,
CODEBUFF_CLI_BINARY_NAME: 'freebuff',
CODEBUFF_CLI_VERSION: '9.8.7',
CODEBUFF_CLI_TARGET: 'win32-x64',
},
},
)

expect(result.status).toBe(0)
expect(result.stderr).toBe('')
expect(JSON.parse(result.stdout)).toEqual({
schemaVersion: BUILD_IDENTITY_SCHEMA_VERSION,
product: 'freebuff',
version: '9.8.7',
target: 'win32-x64',
})
})
})
36 changes: 28 additions & 8 deletions cli/src/__tests__/launcher-avx2-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ async function waitFor(done: () => boolean, timeoutMs = 5000) {
* matters beyond the assertions: a relaunched child's handler firing after a
* test would otherwise call the real process.exit and take the runner down.
*/
const launcher = { lines: [] as string[], exitCodes: [] as (number | undefined)[] }
const launcher = {
lines: [] as string[],
exitCodes: [] as (number | undefined)[],
}
let restoreLauncherCapture = () => {}

function captureLauncherOutput() {
Expand All @@ -109,9 +112,23 @@ function captureLauncherOutput() {
/** A tar.gz holding a single `freebuff.exe` that runs `script`. */
function baselineTarball(script: string) {
const stageDir = mkdtempSync(join(tmpdir(), 'launcher-baseline-'))
writeFileSync(join(stageDir, 'freebuff.exe'), `#!/bin/sh\n${script}\n`, {
mode: 0o755,
})
const identity = JSON.stringify({
schemaVersion: 1,
product: 'freebuff',
version: '1.2.3',
target: 'win32-x64-baseline',
})
writeFileSync(
join(stageDir, 'freebuff.exe'),
`#!/bin/sh
if [ "$1" = "--print-build-info" ]; then
printf '%s\\n' '${identity}'
exit 0
fi
${script}
`,
{ mode: 0o755 },
)
const archive = join(stageDir, 'out.tar.gz')
execFileSync('tar', ['-czf', archive, '-C', stageDir, 'freebuff.exe'])
return readFileSync(archive)
Expand Down Expand Up @@ -201,9 +218,9 @@ describe('windows AVX2 detection', () => {

expect(t.detectMachineHasAvx2()).toBe(false)
expect(t.readCachedAvx2()).toBe(false)
expect(JSON.parse(readFileSync(t.getCpuFeatureCachePath(), 'utf8'))).toEqual(
{ avx2: false },
)
expect(
JSON.parse(readFileSync(t.getCpuFeatureCachePath(), 'utf8')),
).toEqual({ avx2: false })
})

test('a recorded failure selects baseline up front on the NEXT launch', () => {
Expand Down Expand Up @@ -265,7 +282,10 @@ describe('windows AVX2 detection', () => {
describe('recovery after a recorded failure', () => {
/** Simulate a completed install of `target` at `version`. */
function installBinary(t: ReturnType<typeof makeLauncher>, target: string) {
writeFileSync(t.CONFIG.metadataPath, JSON.stringify({ version: '1.2.3', target }))
writeFileSync(
t.CONFIG.metadataPath,
JSON.stringify({ version: '1.2.3', target }),
)
writeFileSync(t.CONFIG.binaryPath, 'pretend binary')
}

Expand Down
Loading
Loading