mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
perf(wsl): return orcad's startup compile memory with a malloc mmap threshold (#34187)
V8 frees ~70 MB after compiling orcad's bundle, but glibc keeps it stranded in the main arena. WSL launches of this build's own slot now set GLIBC_TUNABLES=glibc.malloc.mmap_threshold=16384; orcad and its daemon remove it at startup and orcad hands it only to the daemon, so shells, agents and git keep the user's malloc. Older slots never get it.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import '../memory/host-malloc-tunables-claim'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DAEMON_V8_FLAGS, spawnDaemonChildProcess } from './daemon-launched-child-spawn'
|
||||
import {
|
||||
claimHostMallocTunables,
|
||||
HOST_MALLOC_TUNABLES,
|
||||
hostMallocTunablesLaunchEnv,
|
||||
resetHostMallocTunablesForTests
|
||||
} from '../memory/host-malloc-tunables'
|
||||
|
||||
const { spawn, fork } = vi.hoisted(() => ({ spawn: vi.fn(), fork: vi.fn() }))
|
||||
vi.mock('@orca/process-host', () => ({ spawnProcess: spawn }))
|
||||
@@ -150,3 +156,20 @@ describe('daemon launch environment', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('daemon malloc tunables', () => {
|
||||
afterEach(() => resetHostMallocTunablesForTests())
|
||||
|
||||
it('hands an orcad launch’s claimed tunables to the daemon on both launch paths', () => {
|
||||
claimHostMallocTunables(Object.fromEntries(hostMallocTunablesLaunchEnv()))
|
||||
spawnDaemonChildProcess(options, true)
|
||||
spawnDaemonChildProcess(options, false)
|
||||
expect(spawn.mock.calls.at(-1)?.[0].env.GLIBC_TUNABLES).toBe(HOST_MALLOC_TUNABLES)
|
||||
expect(fork.mock.calls.at(-1)?.[0].env.GLIBC_TUNABLES).toBe(HOST_MALLOC_TUNABLES)
|
||||
})
|
||||
|
||||
it('adds nothing when the launch set none', () => {
|
||||
spawnDaemonChildProcess(options, false)
|
||||
expect(fork.mock.calls.at(-1)?.[0].env.GLIBC_TUNABLES).toBe(process.env.GLIBC_TUNABLES)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { spawnProcess } from '@orca/process-host'
|
||||
import type { SpawnedProcess } from '@orca/process-host/process-spec'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env'
|
||||
import { hostMallocTunablesDaemonEnv } from '../memory/host-malloc-tunables'
|
||||
import {
|
||||
appImageDaemonIdentityPath,
|
||||
buildAppImageDaemonCommand,
|
||||
@@ -92,6 +93,7 @@ export function spawnDaemonChildProcess(
|
||||
const daemonEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
...hostMallocTunablesDaemonEnv(),
|
||||
// The detached daemon lacks AppEnvironment; rcfiles must stay outside swept temporary directories.
|
||||
ORCA_USER_DATA_PATH: userDataPath
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ import {
|
||||
stripLegacyTerminalShimEnv
|
||||
} from '../pty/legacy-terminal-shim-dir'
|
||||
import { mockPtyProcess, useDaemonPtySubprocessEnv } from './pty-subprocess-test-harness'
|
||||
import {
|
||||
claimHostMallocTunables,
|
||||
hostMallocTunablesLaunchEnv,
|
||||
resetHostMallocTunablesForTests
|
||||
} from '../memory/host-malloc-tunables'
|
||||
|
||||
describe('createPtySubprocess', () => {
|
||||
useDaemonPtySubprocessEnv({
|
||||
@@ -308,6 +313,28 @@ describe('createPtySubprocess', () => {
|
||||
expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined()
|
||||
})
|
||||
|
||||
it('gives shells the user’s malloc once a host daemon claims its launch tunables', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
for (const [name, value] of hostMallocTunablesLaunchEnv()) {
|
||||
process.env[name] = value
|
||||
}
|
||||
try {
|
||||
claimHostMallocTunables()
|
||||
await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
|
||||
} finally {
|
||||
for (const [name] of hostMallocTunablesLaunchEnv()) {
|
||||
delete process.env[name]
|
||||
}
|
||||
resetHostMallocTunablesForTests()
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
for (const [name] of hostMallocTunablesLaunchEnv()) {
|
||||
expect(env[name]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not forward a half-activated conda env from the daemon process env', async () => {
|
||||
// Main never sees the daemon’s inherited environment; sanitize incomplete conda activation here too.
|
||||
const proc = mockPtyProcess()
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function launchOrcadAndAwaitReadiness(
|
||||
target.host,
|
||||
spec,
|
||||
target.conn.orcadLaunchDetach ?? 'nohup',
|
||||
(await target.conn.orcadLaunchEnv?.()) ?? []
|
||||
(await target.conn.orcadLaunchEnv?.({ fullVersion: spec.fullVersion })) ?? []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Imported first by host entries so the claim precedes any child process.
|
||||
import { claimHostMallocTunables } from './host-malloc-tunables'
|
||||
|
||||
claimHostMallocTunables()
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
claimHostMallocTunables,
|
||||
HOST_MALLOC_TUNABLES,
|
||||
hostMallocTunablesDaemonEnv,
|
||||
hostMallocTunablesLaunchEnv,
|
||||
resetHostMallocTunablesForTests
|
||||
} from './host-malloc-tunables'
|
||||
|
||||
afterEach(() => resetHostMallocTunablesForTests())
|
||||
|
||||
describe('host malloc tunables', () => {
|
||||
it('launches with the mmap threshold glibc reads at exec', () => {
|
||||
expect(Object.fromEntries(hostMallocTunablesLaunchEnv()).GLIBC_TUNABLES).toBe(
|
||||
'glibc.malloc.mmap_threshold=16384'
|
||||
)
|
||||
})
|
||||
|
||||
it('removes what the launch set from this process, and keeps it only for the daemon', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
PATH: '/bin',
|
||||
...Object.fromEntries(hostMallocTunablesLaunchEnv())
|
||||
}
|
||||
claimHostMallocTunables(env)
|
||||
expect(env).toEqual({ PATH: '/bin' })
|
||||
expect(hostMallocTunablesDaemonEnv()).toEqual(Object.fromEntries(hostMallocTunablesLaunchEnv()))
|
||||
})
|
||||
|
||||
it('never touches tunables the user set', () => {
|
||||
const env: NodeJS.ProcessEnv = { GLIBC_TUNABLES: 'glibc.malloc.arena_max=2' }
|
||||
claimHostMallocTunables(env)
|
||||
expect(env).toEqual({ GLIBC_TUNABLES: 'glibc.malloc.arena_max=2' })
|
||||
expect(hostMallocTunablesDaemonEnv()).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps a user value that replaced the launch’s, dropping only the marker', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...Object.fromEntries(hostMallocTunablesLaunchEnv()),
|
||||
GLIBC_TUNABLES: 'glibc.malloc.arena_max=2'
|
||||
}
|
||||
claimHostMallocTunables(env)
|
||||
expect(env).toEqual({ GLIBC_TUNABLES: 'glibc.malloc.arena_max=2' })
|
||||
expect(hostMallocTunablesDaemonEnv()).toEqual({})
|
||||
})
|
||||
|
||||
it('gives the daemon nothing in a process the launch did not tune', () => {
|
||||
claimHostMallocTunables({})
|
||||
expect(hostMallocTunablesDaemonEnv()).toEqual({})
|
||||
expect(HOST_MALLOC_TUNABLES).toContain('mmap_threshold')
|
||||
})
|
||||
})
|
||||
|
||||
describe('host process entries', () => {
|
||||
it.each(['../orcad/main.ts', '../daemon/daemon-entry.ts'])(
|
||||
'%s claims the tunables before importing anything that could start a child',
|
||||
async (entry) => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const source = await readFile(new URL(entry, import.meta.url), 'utf8')
|
||||
expect(source.match(/^import .*$/m)?.[0]).toBe(
|
||||
"import '../memory/host-malloc-tunables-claim'"
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* glibc keeps the ~70 MB V8 frees after compiling orcad's bundle stranded in its main arena; serving
|
||||
* allocations of 16 KB and up from mmap returns them (WSL host orcad 232 -> 192 MB, daemon 79 -> 71 MB
|
||||
* at idle, flood throughput unchanged). glibc reads GLIBC_TUNABLES only at exec, so the launch sets it
|
||||
* and each host process removes it at once: shells, agents and git must keep the user's malloc.
|
||||
*/
|
||||
export const HOST_MALLOC_TUNABLES = 'glibc.malloc.mmap_threshold=16384'
|
||||
const GLIBC_TUNABLES = 'GLIBC_TUNABLES'
|
||||
/** Says the launch, not the user, set GLIBC_TUNABLES; nothing else may be removed. */
|
||||
const HOST_MALLOC_TUNABLES_ENV = 'ORCA_HOST_MALLOC_TUNABLES'
|
||||
|
||||
let claimed: string | null = null
|
||||
|
||||
export function hostMallocTunablesLaunchEnv(): [string, string][] {
|
||||
return [
|
||||
[GLIBC_TUNABLES, HOST_MALLOC_TUNABLES],
|
||||
[HOST_MALLOC_TUNABLES_ENV, HOST_MALLOC_TUNABLES]
|
||||
]
|
||||
}
|
||||
|
||||
/** Run before this process starts any child. */
|
||||
export function claimHostMallocTunables(env: NodeJS.ProcessEnv = process.env): void {
|
||||
const value = env[HOST_MALLOC_TUNABLES_ENV]
|
||||
if (value === undefined) {
|
||||
return
|
||||
}
|
||||
delete env[HOST_MALLOC_TUNABLES_ENV]
|
||||
if (env[GLIBC_TUNABLES] === value) {
|
||||
delete env[GLIBC_TUNABLES]
|
||||
claimed = value
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the daemon, another host process, inherits what this one claimed. */
|
||||
export function hostMallocTunablesDaemonEnv(): Record<string, string> {
|
||||
return claimed === null ? {} : { [GLIBC_TUNABLES]: claimed, [HOST_MALLOC_TUNABLES_ENV]: claimed }
|
||||
}
|
||||
|
||||
export function resetHostMallocTunablesForTests(): void {
|
||||
claimed = null
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../memory/host-malloc-tunables-claim'
|
||||
import process from 'node:process'
|
||||
import { main } from './orcad-entry'
|
||||
import { resolveOrcadExitCode } from './orcad-exit-code'
|
||||
|
||||
@@ -15,7 +15,12 @@ export type OrcadHostTransport = RemoteInstallTransport & {
|
||||
/** Absent on SSH, which detaches with nohup. */
|
||||
readonly orcadLaunchDetach?: OrcadLaunchDetach
|
||||
/** Pass client-only host facts at launch; SSH adds none. */
|
||||
readonly orcadLaunchEnv?: () => Promise<readonly OrcadLaunchEnvEntry[]>
|
||||
readonly orcadLaunchEnv?: (
|
||||
launch: OrcadLaunchEnvRequest
|
||||
) => Promise<readonly OrcadLaunchEnvEntry[]>
|
||||
}
|
||||
|
||||
/** The slot being launched; entries only its own build understands must check the version. */
|
||||
export type OrcadLaunchEnvRequest = { readonly fullVersion: string }
|
||||
|
||||
export type OrcadLaunchEnvEntry = readonly [name: string, value: string]
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
wslOrcadDeferralCode,
|
||||
wslOrcadDeferralFailure
|
||||
} from './wsl-orcad-update-admission'
|
||||
import { withOwnBuildLaunchEnv } from './wsl-orcad-own-build-launch-env'
|
||||
|
||||
/** An update this app carries that the host may not take yet; the incumbent keeps serving. */
|
||||
export type WslOrcadDeferredUpdate = WslHostDeferredUpdate
|
||||
@@ -126,10 +127,12 @@ export async function deployAndLaunchWslOrcad(
|
||||
async function launchOverTransport(
|
||||
name: string,
|
||||
target: WslOrcadHostTarget,
|
||||
conn: OrcadHostTransport,
|
||||
transport: OrcadHostTransport,
|
||||
options: WslOrcadDeployOptions
|
||||
): Promise<WslOrcadLaunch> {
|
||||
const { signal } = options
|
||||
let ownBuildVersion: string | null = null
|
||||
const conn = withOwnBuildLaunchEnv(transport, () => ownBuildVersion)
|
||||
const kernel = await execCommand(conn, 'uname -r', { signal })
|
||||
if (isWsl1KernelRelease(kernel)) {
|
||||
throw new OrcadHostUnsupportedError(
|
||||
@@ -163,6 +166,7 @@ async function launchOverTransport(
|
||||
})
|
||||
const appVersion = getAppEnvironment().getVersion()
|
||||
const candidateVersion = readLocalFullVersion(localOrcadDir)
|
||||
ownBuildVersion = candidateVersion
|
||||
const failureHost = { kind: 'wsl', id: target.id } as const
|
||||
const intent = options.intent ?? 'connect'
|
||||
const admit = (record: OrcadActivationRecord, admitIntent: OrcadUpdateIntent) =>
|
||||
|
||||
@@ -205,9 +205,9 @@ describe('createWslOrcadHostTransport', () => {
|
||||
commandLoops: false,
|
||||
readDistroRegistryId: async (distro) => (distro === 'Ubuntu-24.04' ? id : null)
|
||||
})
|
||||
await expect(make('1a2b')?.orcadLaunchEnv?.()).resolves.toEqual([
|
||||
await expect(make('1a2b')?.orcadLaunchEnv?.({ fullVersion: '0.1.0+abc' })).resolves.toEqual([
|
||||
[ORCAD_WSL_DISTRO_GUID_ENV, '1a2b']
|
||||
])
|
||||
await expect(make(null)?.orcadLaunchEnv?.()).resolves.toEqual([])
|
||||
await expect(make(null)?.orcadLaunchEnv?.({ fullVersion: '0.1.0+abc' })).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hostMallocTunablesLaunchEnv } from '../memory/host-malloc-tunables'
|
||||
import type { OrcadHostTransport } from '../ssh/orcad-host-transport'
|
||||
import { withOwnBuildLaunchEnv } from './wsl-orcad-own-build-launch-env'
|
||||
|
||||
function transport(): OrcadHostTransport {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wrapper reads only orcadLaunchEnv.
|
||||
return {
|
||||
orcadLaunchEnv: async () => [['ORCA_WSL_DISTRO_GUID', 'g']]
|
||||
} as unknown as OrcadHostTransport
|
||||
}
|
||||
|
||||
describe('withOwnBuildLaunchEnv', () => {
|
||||
it('adds the malloc tunables when the launched slot is this build', async () => {
|
||||
const conn = withOwnBuildLaunchEnv(transport(), () => '0.1.0+own')
|
||||
await expect(conn.orcadLaunchEnv?.({ fullVersion: '0.1.0+own' })).resolves.toEqual([
|
||||
['ORCA_WSL_DISTRO_GUID', 'g'],
|
||||
...hostMallocTunablesLaunchEnv()
|
||||
])
|
||||
})
|
||||
|
||||
it('withholds them from an older slot, which would leak them to user shells', async () => {
|
||||
const conn = withOwnBuildLaunchEnv(transport(), () => '0.1.0+own')
|
||||
await expect(conn.orcadLaunchEnv?.({ fullVersion: '0.1.0+older' })).resolves.toEqual([
|
||||
['ORCA_WSL_DISTRO_GUID', 'g']
|
||||
])
|
||||
})
|
||||
|
||||
it('withholds them before this build’s version is known', async () => {
|
||||
const conn = withOwnBuildLaunchEnv(transport(), () => null)
|
||||
await expect(conn.orcadLaunchEnv?.({ fullVersion: '0.1.0+own' })).resolves.toEqual([
|
||||
['ORCA_WSL_DISTRO_GUID', 'g']
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { hostMallocTunablesLaunchEnv } from '../memory/host-malloc-tunables'
|
||||
import type { OrcadHostTransport } from '../ssh/orcad-host-transport'
|
||||
|
||||
/** An older slot would leak GLIBC_TUNABLES to user shells, so only this build's own slot gets it. */
|
||||
export function withOwnBuildLaunchEnv(
|
||||
conn: OrcadHostTransport,
|
||||
ownBuildVersion: () => string | null
|
||||
): OrcadHostTransport {
|
||||
const launchEnv = conn.orcadLaunchEnv
|
||||
return {
|
||||
...conn,
|
||||
orcadLaunchEnv: async (launch) => [
|
||||
...((await launchEnv?.(launch)) ?? []),
|
||||
...(launch.fullVersion === ownBuildVersion() ? hostMallocTunablesLaunchEnv() : [])
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user