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
36 changes: 26 additions & 10 deletions electron/src/backend/CLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,20 @@ export default class CLI {
return result?.version
}

async agentReload(): Promise<{ version?: string; error?: Error }> {
let error: Error | undefined
const result = await this.exec({
cmds: [strings.agentReload()],
skipSignInCheck: true,
skipInstalledCheck: true,
quiet: true,
onError: e => {
error = e
},
})
return { version: result?.version, error }
}

async exec({
cmds,
checkAuthHash = false,
Expand All @@ -324,17 +338,19 @@ export default class CLI {
let commands = new Command({ admin, quiet })
cmds.forEach(cmd => commands.push(`"${cliBinary.path}" ${cmd}`))

if (!skipInstalledCheck) {
commands.onError = async (e: Error) => {
if (!quiet) {
if (typeof onError === 'function') onError(e)
EventBus.emit(this.EVENTS.error, e.message)
}
// can't decrypt authHash
if (e.name === '11') await this.signOut()

binaryInstaller.check()
commands.onError = async (e: Error) => {
if (skipInstalledCheck) {
if (typeof onError === 'function') onError(e)
return
}
if (!quiet) {
if (typeof onError === 'function') onError(e)
EventBus.emit(this.EVENTS.error, e.message)
}
// can't decrypt authHash
if (e.name === '11') await this.signOut()

binaryInstaller.check()
}

const result = await commands.exec()
Expand Down
3 changes: 3 additions & 0 deletions electron/src/backend/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const IGNORED_MESSAGES = [
'read-only file system',
'user did not grant permission', // elevation prompt dismissed
'no polkit authentication agent found', // no way to prompt for elevation
'unknown-message', // agent predates the command, e.g. reload sent to an older daemon
]

// CLI exit codes that report an expected state rather than a defect. Each is
Expand All @@ -30,6 +31,8 @@ const EXPECTED_CLI_CODES = [
'12', // config - you must be signed in
'101', // agent not reachable
'101001', // agent version mismatch
'409', // control - reload not supported on this platform
'410', // control - agent service predates reload support
'7003', // cmd - you must run this command with elevated privileges
]

Expand Down
101 changes: 101 additions & 0 deletions electron/src/backend/binaryInstaller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ describe('Test framework is working', () => {
expect(true).toBeTruthy()
})

const refusal = (code: string, message = 'refused') => {
const error = new Error(message)
error.name = code
return error
}

describe('backend/binaryInstaller', () => {
const version = '0.37.6'
const agentVersion = '0.37.6'
Expand Down Expand Up @@ -126,11 +132,14 @@ describe('backend/binaryInstaller', () => {
let agentVersionSpy: jest.SpyInstance
let prefSpy: jest.SpyInstance
let agentSpy: jest.SpyInstance
let reloadSpy: jest.SpyInstance
let updateSpy: jest.SpyInstance
let binary: Binary
let path: string

beforeAll(() => {
environment.isWindows = false
environment.isHeadless = false
binary = new Binary({ name, version })
path = binary.path
})
Expand All @@ -144,8 +153,11 @@ describe('backend/binaryInstaller', () => {
agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(true))
versionSpy = jest.spyOn(cli, 'version').mockImplementation(() => Promise.resolve(version))
agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve(agentVersion))
reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ error: refusal('410') }))
updateSpy = jest.spyOn(preferences, 'set').mockImplementation()
jest.spyOn(fs, 'existsSync').mockImplementation(() => true)
environment.version = desktopVersion
binaryInstaller.reloadRefusedBy = undefined
})

afterEach(() => {
Expand All @@ -155,6 +167,92 @@ describe('backend/binaryInstaller', () => {
versionSpy.mockClear()
agentVersionSpy.mockClear()
agentSpy.mockClear()
reloadSpy.mockClear()
updateSpy.mockClear()
})

test('reloads the running agent instead of prompting when only the agent version differs', async () => {
agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0'))
reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ version }))

await binaryInstaller.check()

expect(reloadSpy).toBeCalledTimes(1)
expect(installSpy).toBeCalledTimes(0)
expect(eventSpy).toBeCalledWith('binary/installed', { path, version, name, installedVersion: version })
expect(eventSpy).not.toBeCalledWith('binary/not-installed', expect.anything())
expect(updateSpy).toBeCalledWith({ version: desktopVersion, cliVersion: version })
expect(binaryInstaller.inProgress).toBe(false)
})

test('does not latch a reload that failed because the agent was unreachable', async () => {
agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0'))
reloadSpy = jest
.spyOn(cli, 'agentReload')
.mockImplementation(() => Promise.resolve({ error: refusal('101', 'agent not reachable') }))

await binaryInstaller.check()
expect(binaryInstaller.reloadRefusedBy).toBeUndefined()

await binaryInstaller.check()
expect(reloadSpy).toBeCalledTimes(2)
})

test('latches a refusal the agent will repeat', async () => {
agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0'))
reloadSpy = jest
.spyOn(cli, 'agentReload')
.mockImplementation(() => Promise.resolve({ error: refusal('3', 'UNKNOWN-MESSAGE: {}') }))

await binaryInstaller.check()
await binaryInstaller.check()

expect(reloadSpy).toBeCalledTimes(1)
expect(binaryInstaller.reloadRefusedBy).toBe('2.0.0')
})

test('treats a reload that leaves another version running as refused', async () => {
agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0'))
reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ version: '2.0.0' }))

await binaryInstaller.check()

expect(reloadSpy).toBeCalledTimes(1)
expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentMismatched: true }))
expect(eventSpy).not.toBeCalledWith('binary/installed', expect.anything())
expect(binaryInstaller.reloadRefusedBy).toBe('2.0.0')
})

test('does not retry a refused reload until the agent itself changes', async () => {
agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0'))

await binaryInstaller.check()
await binaryInstaller.check()
expect(reloadSpy).toBeCalledTimes(1)
expect(eventSpy).toBeCalledTimes(2)
expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentMismatched: true }))

agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(false))
await binaryInstaller.check()
agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(true))
await binaryInstaller.check()
expect(reloadSpy).toBeCalledTimes(1)
expect(binaryInstaller.reloadRefusedBy).toBe('2.0.0')

agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('3.0.0'))
await binaryInstaller.check()
expect(reloadSpy).toBeCalledTimes(2)
})

test('clears a refusal once the agent has been brought current', async () => {
binaryInstaller.reloadRefusedBy = '1.0.0'
agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0'))
reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ version }))

await binaryInstaller.check()

expect(binaryInstaller.reloadRefusedBy).toBeUndefined()
expect(eventSpy).toBeCalledWith('binary/installed', expect.anything())
})

test('should notify if installed', async () => {
Expand Down Expand Up @@ -186,6 +284,7 @@ describe('backend/binaryInstaller', () => {
desktopUpdated: false,
})
expect(versionSpy).toBeCalledTimes(1)
expect(reloadSpy).toBeCalledTimes(0)
})

test('should notify if agent version is wrong', async () => {
Expand All @@ -206,6 +305,7 @@ describe('backend/binaryInstaller', () => {
desktopUpdated: false,
})
expect(agentVersionSpy).toBeCalledTimes(1)
expect(reloadSpy).toBeCalledTimes(1)
})

test('should notify with different installed cli version', async () => {
Expand Down Expand Up @@ -240,6 +340,7 @@ describe('backend/binaryInstaller', () => {
desktopUpdated: false,
})
expect(versionSpy).toBeCalledTimes(1)
expect(reloadSpy).toBeCalledTimes(0)
})

test('should notify if desktop version updated', async () => {
Expand Down
64 changes: 52 additions & 12 deletions electron/src/backend/binaryInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,20 @@ import Binary, { binaries, cliBinary } from './Binary'
import { existsSync, lstatSync } from 'fs'
import Logger from './Logger'

// Refusals the running agent will give again: unsupported platform, a service definition that
// predates staging, a signature it will not accept, and a daemon too old to know the command.
const PERMANENT_REFUSAL_CODES = ['409', '410', '411']

function permanentRefusal(error?: Error) {
if (!error) return false
return PERMANENT_REFUSAL_CODES.includes(error.name) || error.message.toLowerCase().includes('unknown-message')
}

export class BinaryInstaller {
ready = false
inProgress = false
uninstallInitiated = false
reloadRefusedBy?: string
binaries: Binary[]
cliBinary: Binary

Expand All @@ -36,6 +46,7 @@ export class BinaryInstaller {

if (shouldInstall) {
if (environment.isElevated) return await this.install()
if (this.canReload(status) && (await this.reload())) return
return EventBus.emit(Binary.EVENTS.notInstalled, status)
} else if (!this.ready) {
Logger.info('INSTALLER DONE')
Expand All @@ -58,18 +69,56 @@ export class BinaryInstaller {
return status
}

// The running agent decides whether it can adopt the installed binaries itself.
canReload(status: BinaryReason) {
return !status.binariesOutdated && !status.agentStopped && this.reloadRefusedBy !== this.cliBinary.agentVersion
}

async reload(): Promise<boolean> {
if (this.inProgress) {
Logger.info('AGENT RELOAD ALREADY IN PROGRESS')
return true
}
Logger.info('START AGENT RELOAD')
this.inProgress = true

const { version, error } = await cli.agentReload()
const reloaded = !!version && version === this.cliBinary.version
if (reloaded) {
await this.completeInstall()
} else {
// A daemon that answered, or refused for a reason it will repeat, is worth latching. An
// unreachable agent or a failed spawn is not, or the unprivileged retry never happens again.
if (!!version || permanentRefusal(error)) this.reloadRefusedBy = this.cliBinary.agentVersion
Logger.warn('AGENT RELOAD REFUSED', {
version,
code: error?.name,
error: error?.message,
latched: this.reloadRefusedBy,
agentVersion: this.cliBinary.agentVersion,
})
}

this.inProgress = false
return reloaded
}

async install() {
if (this.inProgress) return Logger.warn('INSTALL IN PROGRESS', { error: 'Can not install while in progress' })
Logger.info('START INSTALLATION')
this.inProgress = true

await this.installBinaries().catch(error => EventBus.emit(Binary.EVENTS.error, error))
await this.completeInstall()

this.inProgress = false
}

private async completeInstall() {
this.reloadRefusedBy = undefined
EventBus.emit(Binary.EVENTS.installed, this.cliBinary.toJSON())
EventBus.emit(ConnectionPool.EVENTS.clearErrors)
await this.updateVersions()

this.inProgress = false
this.ready = true
}

Expand Down Expand Up @@ -122,15 +171,6 @@ export class BinaryInstaller {
})
}

async restart() {
const commands = new Command({ onError: e => EventBus.emit(Binary.EVENTS.error, e.toString()), admin: true })
commands.push(`${this.envVar()} "${this.cliBinary.path}" ${strings.serviceRestart()}`)

this.inProgress = true
await commands.exec()
this.inProgress = false
}

async uninstall() {
if (this.inProgress) return Logger.warn('UNINSTALL IN PROGRESS', { error: 'Can not uninstall while in progress' })
Logger.info('START UNINSTALL')
Expand Down Expand Up @@ -202,7 +242,7 @@ export class BinaryInstaller {
}

async updateVersions() {
const cliVersion = await cli.version()
const cliVersion = this.cliBinary.installedVersion || (await cli.version())
Logger.info('CLI VERSION UPDATE', { cliVersion })
preferences.set({ version: environment.version, cliVersion })
}
Expand Down
4 changes: 2 additions & 2 deletions electron/src/backend/cliStrings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ export default {
return `-j agent uninstall`
},

serviceRestart() {
return `-j agent restart`
agentReload() {
return '-j agent reload'
},

toolsInstall() {
Expand Down
Loading