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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ machine-wide, it prompts you and waits for your permission:
the shared-memory dialog appears, it lists this change as recommended, and GemDB Code runs it in
its own terminal. Otherwise, click the **Survives logout** row in the GemDB Code sidebar, or run
**GemDB: Keep the Database Running After Logout** from the Command Palette. The change takes
effect after you restart your computer.
effect at once. If systemd cannot load it straight away, the row stays and reads **not yet
applied** until you restart your computer.

#### Your shell profile and AI clients

Expand Down
8 changes: 7 additions & 1 deletion docs/automation-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,13 @@ modal when that is being asked anyway. Otherwise the status view's "Survives
logout" row offers it (`gemdb.configureRemoveIpc`). It used to raise the modal
alone, which on stock Linux (where shared memory is already far above 1 GB)
meant every Linux user was asked for `sudo` over a setting that blocks
nothing (issue #45).
nothing (issue #45). The script sends logind SIGHUP so the setting applies
without a reboot, and the row reads logind's live `RemoveIPC` property rather
than its config files: logind consults the loaded value when a user's last
session ends, so a file it has not reloaded protects nothing, and checking
the file let the row vanish while a logout still killed the database.
Measured 2026-10-12 on systemd 249 and 255: the reload applies it, no restart
of logind, and a segment survives a logout that destroys it under the default.

**Choosing another root path when the default is on NFS — asked, once setup
has refused.** The stone will not open a repository on an NFS mount, and on
Expand Down
21 changes: 20 additions & 1 deletion resources/setRemoveIPC.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,23 @@ RemoveIPC=no
EOF

echo "Configured at $CONF_FILE"
echo "To apply: restart your computer, or run: sudo systemctl restart systemd-logind"

# Writing the file is not enough: the running logind keeps the value it last
# loaded, and it reads that value when a user's last session ends -- so until
# it reloads, a logout still destroys the database. logind re-reads its config
# on SIGHUP (every systemd since at least v245), which applies the change now
# without the session-disrupting restart. `systemctl kill` rather than
# `reload` because older units define no ExecReload.
if systemctl kill --signal=SIGHUP systemd-logind.service 2>/dev/null; then
# The reload is asynchronous; wait briefly for the running value to change.
for _ in 1 2 3 4 5 6 7 8 9 10; do
if [ "$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
org.freedesktop.login1.Manager RemoveIPC 2>/dev/null)" = "b false" ]; then
echo "Applied: systemd-logind now keeps IPC at logout."
exit 0
fi
sleep 0.2
done
fi

echo "systemd-logind has not loaded the change. Restart your computer to apply it."
8 changes: 6 additions & 2 deletions scripts/unset-os-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,12 @@ Linux)
if [ -f "${IPC_CONF}" ]; then
rm -f "${IPC_CONF}"
echo "Removed ${IPC_CONF}"
echo " RemoveIPC goes back to the systemd default at the next boot, or now"
echo " with: sudo systemctl restart systemd-logind"
# logind re-reads its config on SIGHUP, as setRemoveIPC.sh relies on.
if systemctl kill --signal=SIGHUP systemd-logind.service 2>/dev/null; then
echo " systemd-logind reloaded: RemoveIPC is back to the systemd default"
else
echo " RemoveIPC goes back to the systemd default at the next boot"
fi
fi

# The kernel's own default since 3.16: ULONG_MAX - (1 << 24), i.e. no
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/activation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ const ensureOsConfigured = vi.fn(async (): Promise<string> => 'alreadyConfigured
const isSharedMemoryConfigured = vi.fn(async () => false);
vi.mock('../osConfig', async (importOriginal) => ({
osConfigAllowsStart: (await importOriginal<typeof import('../osConfig')>()).osConfigAllowsStart,
REMOVE_IPC_STATUS: (await importOriginal<typeof import('../osConfig')>()).REMOVE_IPC_STATUS,
configureSharedMemory: async () => {},
configureRemoveIpc: async () => {},
ensureOsConfigured: () => ensureOsConfigured(),
isSharedMemoryConfigured: () => isSharedMemoryConfigured(),
isRemoveIpcConfigured: () => false,
removeIpcStatus: async () => 'unset',
sharedMemoryLabel: async () => '',
}));

Expand Down
43 changes: 41 additions & 2 deletions src/__tests__/osConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { REQUIRED_SHARED_MEMORY_GB } from '../config';
import { OsConfigWorld, runEnsureOsConfigured } from '../osConfig';
import {
OsConfigWorld,
parseRemoveIpcProperty,
removeIpcStatusFrom,
runEnsureOsConfigured,
} from '../osConfig';

/**
* A stand-in operating system that records what was asked of it.
Expand Down Expand Up @@ -31,7 +36,8 @@ function makeWorld(options: {
const world: OsConfigWorld = {
sharedMemoryOk: () =>
Promise.resolve(sharedMemory === 'ok' || (sharedMemory === 'fixed' && sharedMemoryRan)),
removeIpcOk: () => removeIpc === 'ok' || (removeIpc === 'fixed' && removeIpcRan),
removeIpcOk: () =>
Promise.resolve(removeIpc === 'ok' || (removeIpc === 'fixed' && removeIpcRan)),
confirm: (message) => {
calls.push('confirm');
messages.push(message);
Expand Down Expand Up @@ -164,3 +170,36 @@ describe('runEnsureOsConfigured', () => {
expect(recommended).toContain('RemoveIPC=no');
});
});

describe('removeIpcStatusFrom', () => {
it('is not in effect while only the config file has it', () => {
// The state the status view used to call fixed: the script wrote the
// file, logind never reloaded it, and a logout still killed the database.
expect(removeIpcStatusFrom(false, true)).toBe('pending');
});

it('trusts the running logind over the config files', () => {
expect(removeIpcStatusFrom(true, false)).toBe('inEffect');
expect(removeIpcStatusFrom(false, false)).toBe('unset');
});

it('falls back to the config files when logind cannot be asked', () => {
expect(removeIpcStatusFrom(undefined, true)).toBe('inEffect');
expect(removeIpcStatusFrom(undefined, false)).toBe('unset');
});
});

describe('parseRemoveIpcProperty', () => {
it('reads RemoveIPC=no as keeping IPC', () => {
expect(parseRemoveIpcProperty('b false\n')).toBe(true);
});

it('reads RemoveIPC=yes as removing IPC', () => {
expect(parseRemoveIpcProperty('b true\n')).toBe(false);
});

it('has no answer for output that is not a boolean property', () => {
expect(parseRemoveIpcProperty('')).toBeUndefined();
expect(parseRemoveIpcProperty('s "no"')).toBeUndefined();
});
});
101 changes: 91 additions & 10 deletions src/osConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,92 @@ export async function sharedMemoryLabel(): Promise<string> {
return smallest >= 1024 ? '≥ 1 TB' : `${Math.round(smallest * 1000) / 1000} GB`;
}

/**
* Where `RemoveIPC=no` stands, as far as a logout is concerned.
*
* inEffect — the running logind has it, so a logout leaves the database alone.
* pending — it is written to logind's config, but the running logind has not
* read it, so a logout still destroys the database. Without this
* state the status view called a machine fixed the moment the
* script wrote the file, and the next logout killed the database
* anyway.
* unset — neither.
*/
export const REMOVE_IPC_STATUS = {
inEffect: 'inEffect',
pending: 'pending',
unset: 'unset',
} as const;
export type RemoveIpcStatus = (typeof REMOVE_IPC_STATUS)[keyof typeof REMOVE_IPC_STATUS];

/**
* Combine what the running logind says with what its config files say.
*
* The running value is the truth, because logind reads `remove_ipc` at the
* moment a user's last session ends (`user_finalize`). It is undefined when it
* cannot be read — no `busctl`, or no logind on the bus — and then the config
* files are the best guess left, which is all this check used to read.
*/
export function removeIpcStatusFrom(
running: boolean | undefined,
inConfig: boolean,
): RemoveIpcStatus {
if (running === undefined) return inConfig ? REMOVE_IPC_STATUS.inEffect : REMOVE_IPC_STATUS.unset;
if (running) return REMOVE_IPC_STATUS.inEffect;
return inConfig ? REMOVE_IPC_STATUS.pending : REMOVE_IPC_STATUS.unset;
}

/**
* Read `busctl get-property`'s answer for logind's `RemoveIPC`: true when IPC
* is kept (`RemoveIPC=no`), undefined when the answer is not a boolean.
*/
export function parseRemoveIpcProperty(stdout: string): boolean | undefined {
const match = stdout.trim().match(/^b\s+(true|false)$/);
return match ? match[1] === 'false' : undefined;
}

/**
* Does the running logind keep IPC at logout? Read from its `RemoveIPC`
* D-Bus property, which any user may read — it reflects the config logind
* last loaded, so a file written since then does not count until logind is
* sent SIGHUP (which `setRemoveIPC.sh` does) or restarted.
*/
function isRemoveIpcRunning(): Promise<boolean | undefined> {
return new Promise((resolve) => {
execFile(
'busctl',
[
'get-property',
'org.freedesktop.login1',
'/org/freedesktop/login1',
'org.freedesktop.login1.Manager',
'RemoveIPC',
],
{ encoding: 'utf-8', timeout: 5000 },
(error, stdout) => resolve(error ? undefined : parseRemoveIpcProperty(stdout)),
);
});
}

/** Where `RemoveIPC=no` stands on this machine; always `inEffect` off Linux. */
export async function removeIpcStatus(): Promise<RemoveIpcStatus> {
if (process.platform !== 'linux') return REMOVE_IPC_STATUS.inEffect;
return removeIpcStatusFrom(await isRemoveIpcRunning(), isRemoveIpcInConfig());
}

/** Will the database survive its owner logging out? Always true off Linux. */
export async function isRemoveIpcConfigured(): Promise<boolean> {
return (await removeIpcStatus()) === REMOVE_IPC_STATUS.inEffect;
}

/**
* Does systemd's logind config set `RemoveIPC=no`?
*
* Drop-ins under `logind.conf.d` are applied in alphabetical order after the
* main file, and the last assignment wins — so read them in that order and
* keep the final value rather than stopping at the first hit.
*/
export function isRemoveIpcConfigured(): boolean {
if (process.platform !== 'linux') return true;

function isRemoveIpcInConfig(): boolean {
const dropInDir = '/etc/systemd/logind.conf.d';
let dropIns: string[] = [];
try {
Expand Down Expand Up @@ -164,7 +240,7 @@ export function isRemoveIpcConfigured(): boolean {
*/
export interface OsConfigWorld {
sharedMemoryOk: () => Promise<boolean>;
removeIpcOk: () => boolean;
removeIpcOk: () => Promise<boolean>;
/** Show the modal, already worded. True if the user chose to configure. */
confirm: (message: string) => Promise<boolean>;
runSharedMemoryScript: () => Promise<void>;
Expand Down Expand Up @@ -201,7 +277,7 @@ export async function runEnsureOsConfigured(world: OsConfigWorld): Promise<OsCon
// it opened ran and closed before anyone saw it.
const sharedMemoryOk = await world.sharedMemoryOk();
if (sharedMemoryOk) return OS_CONFIG_RESULT.alreadyConfigured;
const removeIpcOk = world.removeIpcOk();
const removeIpcOk = await world.removeIpcOk();

const missing: OsConfigMissing = removeIpcOk
? OS_CONFIG_MISSING.sharedMemory
Expand Down Expand Up @@ -245,7 +321,7 @@ export async function runEnsureOsConfigured(world: OsConfigWorld): Promise<OsCon
// `stillUnconfigured` would claim the database never started.
if (!removeIpcOk) {
await world.runRemoveIpcScript();
if (!world.removeIpcOk()) {
if (!(await world.removeIpcOk())) {
world.log(
'RemoveIPC is still unset — the database will stop when you log out of this machine.',
);
Expand Down Expand Up @@ -339,12 +415,13 @@ export async function configureSharedMemory(extensionPath: string): Promise<void
* short, which on Linux is nearly every machine.
*/
export async function configureRemoveIpc(extensionPath: string): Promise<void> {
const wasUnset = !isRemoveIpcConfigured();
const wasUnset = !(await isRemoveIpcConfigured());
await runSetupScript(
REMOVE_IPC_TERMINAL,
path.join(extensionPath, 'resources', 'setRemoveIPC.sh'),
);
const configured = isRemoveIpcConfigured();
const status = await removeIpcStatus();
const configured = status === REMOVE_IPC_STATUS.inEffect;
if (wasUnset) {
reportOsConfigPrompted(
TRIGGER.removeIpcCommand,
Expand All @@ -354,8 +431,12 @@ export async function configureRemoveIpc(extensionPath: string): Promise<void> {
}
if (configured) {
void vscode.window.showInformationMessage(
'The database will now keep running when you log out. ' +
'This takes effect after you restart your computer.',
'The database will now keep running when you log out.',
);
} else if (status === REMOVE_IPC_STATUS.pending) {
void vscode.window.showWarningMessage(
'RemoveIPC=no is saved, but systemd has not loaded it yet, so the database will still ' +
'stop if you log out. Restart your computer to apply it.',
);
} else {
void vscode.window.showErrorMessage(
Expand Down
21 changes: 16 additions & 5 deletions src/statusView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ import {
spaceReading,
usedMb,
} from './maintenance';
import { isRemoveIpcConfigured, isSharedMemoryConfigured, sharedMemoryLabel } from './osConfig';
import {
REMOVE_IPC_STATUS,
isSharedMemoryConfigured,
removeIpcStatus,
sharedMemoryLabel,
} from './osConfig';
import {
databaseExists,
databasePath,
Expand Down Expand Up @@ -570,14 +575,20 @@ export class StatusViewProvider implements vscode.TreeDataProvider<Row> {
});

// Only worth a row when it is wrong — on macOS it never applies, and on a
// correctly configured Linux box it is noise.
if (process.platform === 'linux' && !isRemoveIpcConfigured()) {
// correctly configured Linux box it is noise. "Wrong" means the running
// logind would still remove IPC, not just that the config file says so.
const removeIpc = await removeIpcStatus();
if (removeIpc !== REMOVE_IPC_STATUS.inEffect) {
const pending = removeIpc === REMOVE_IPC_STATUS.pending;
rows.push({
label: 'Survives logout',
description: 'not configured',
description: pending ? 'not yet applied' : 'not configured',
tooltip:
'systemd will destroy the database’s shared memory when you log out of this machine. ' +
'Click to fix it — this runs a setup script with sudo.',
(pending
? 'The fix is saved but systemd has not loaded it. Click to apply it now, or ' +
'restart your computer.'
: 'Click to fix it — this runs a setup script with sudo.'),
icon: warn('warning'),
command: {
command: 'gemdb.configureRemoveIpc',
Expand Down
Loading