From cf381255c45114ae5a3861267f46652eed4bfca3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:01:52 -0700 Subject: [PATCH] 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. --- src/main/daemon/daemon-entry.ts | 1 + .../daemon-launched-child-spawn.test.ts | 23 +++++++ .../daemon/daemon-launched-child-spawn.ts | 2 + .../pty-subprocess-env-inheritance.test.ts | 27 ++++++++ .../install/orcad-remote-runtime-control.ts | 2 +- src/main/memory/host-malloc-tunables-claim.ts | 4 ++ src/main/memory/host-malloc-tunables.test.ts | 64 +++++++++++++++++++ src/main/memory/host-malloc-tunables.ts | 41 ++++++++++++ src/main/orcad/main.ts | 1 + src/main/ssh/orcad-host-transport.ts | 7 +- src/main/wsl/wsl-orcad-deploy.ts | 6 +- src/main/wsl/wsl-orcad-host-transport.test.ts | 4 +- .../wsl-orcad-own-build-launch-env.test.ts | 35 ++++++++++ .../wsl/wsl-orcad-own-build-launch-env.ts | 17 +++++ 14 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 src/main/memory/host-malloc-tunables-claim.ts create mode 100644 src/main/memory/host-malloc-tunables.test.ts create mode 100644 src/main/memory/host-malloc-tunables.ts create mode 100644 src/main/wsl/wsl-orcad-own-build-launch-env.test.ts create mode 100644 src/main/wsl/wsl-orcad-own-build-launch-env.ts diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 1e23feff1c3..7d53c37b76d 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -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' diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index f079e04e3ee..a1d787cb132 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -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) + }) +}) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 81018103762..ca9e40a8aab 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -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 } diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 4229c266fc6..8c6c096f628 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -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() diff --git a/src/main/managed-server/install/orcad-remote-runtime-control.ts b/src/main/managed-server/install/orcad-remote-runtime-control.ts index 943876d1f29..792356c76db 100644 --- a/src/main/managed-server/install/orcad-remote-runtime-control.ts +++ b/src/main/managed-server/install/orcad-remote-runtime-control.ts @@ -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 })) ?? [] ) ) } diff --git a/src/main/memory/host-malloc-tunables-claim.ts b/src/main/memory/host-malloc-tunables-claim.ts new file mode 100644 index 00000000000..4ba8122d7be --- /dev/null +++ b/src/main/memory/host-malloc-tunables-claim.ts @@ -0,0 +1,4 @@ +// Imported first by host entries so the claim precedes any child process. +import { claimHostMallocTunables } from './host-malloc-tunables' + +claimHostMallocTunables() diff --git a/src/main/memory/host-malloc-tunables.test.ts b/src/main/memory/host-malloc-tunables.test.ts new file mode 100644 index 00000000000..225294e71ba --- /dev/null +++ b/src/main/memory/host-malloc-tunables.test.ts @@ -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'" + ) + } + ) +}) diff --git a/src/main/memory/host-malloc-tunables.ts b/src/main/memory/host-malloc-tunables.ts new file mode 100644 index 00000000000..4ac9f34ba63 --- /dev/null +++ b/src/main/memory/host-malloc-tunables.ts @@ -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 { + return claimed === null ? {} : { [GLIBC_TUNABLES]: claimed, [HOST_MALLOC_TUNABLES_ENV]: claimed } +} + +export function resetHostMallocTunablesForTests(): void { + claimed = null +} diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index cb8977740a0..26ff71567f7 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -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' diff --git a/src/main/ssh/orcad-host-transport.ts b/src/main/ssh/orcad-host-transport.ts index 16342ad99c0..3a6839b218d 100644 --- a/src/main/ssh/orcad-host-transport.ts +++ b/src/main/ssh/orcad-host-transport.ts @@ -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 orcadLaunchEnv?: ( + launch: OrcadLaunchEnvRequest + ) => Promise } +/** 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] diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts index 70fa1cd9232..78aba2cee89 100644 --- a/src/main/wsl/wsl-orcad-deploy.ts +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -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 { 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) => diff --git a/src/main/wsl/wsl-orcad-host-transport.test.ts b/src/main/wsl/wsl-orcad-host-transport.test.ts index ecd400f6208..ec3162239c2 100644 --- a/src/main/wsl/wsl-orcad-host-transport.test.ts +++ b/src/main/wsl/wsl-orcad-host-transport.test.ts @@ -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([]) }) }) diff --git a/src/main/wsl/wsl-orcad-own-build-launch-env.test.ts b/src/main/wsl/wsl-orcad-own-build-launch-env.test.ts new file mode 100644 index 00000000000..e004517cebd --- /dev/null +++ b/src/main/wsl/wsl-orcad-own-build-launch-env.test.ts @@ -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'] + ]) + }) +}) diff --git a/src/main/wsl/wsl-orcad-own-build-launch-env.ts b/src/main/wsl/wsl-orcad-own-build-launch-env.ts new file mode 100644 index 00000000000..1de96417621 --- /dev/null +++ b/src/main/wsl/wsl-orcad-own-build-launch-env.ts @@ -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() : []) + ] + } +}