[Bug]: Linux launcher leaves the unit stale when service XDG_CONFIG_HOME differs from the manager #35942
Send to my agentSummary#
The Linux service launcher can leave the installed startup definition pointing at the previous runtime when the environment passed to services uses a different configuration directory from the user manager. Installation already locates the manager's unit directory, but launcher reconciliation does not use that location. A reproduction using the production launcher and fake child processes updates the definition in the default-environment control, then leaves it unchanged and skips manager reload with a service-only configuration override.
Before submitting#
- Related issues: related history is listed under References; no open exact duplicate found in targeted open and closed issue and PR searches.
- Removed credentials, tokens, private paths, and sensitive data.
Area#
apps/server
Actual behavior#
Starting the production main() service-launcher entry point with a committed update from 1.2.3 to 1.2.4, a unit at $HOME/.config/systemd/user/t3code.service, and an inherited XDG_CONFIG_HOME=$HOME/service-config leaves the actual unit's ExecStart at 1.2.3. The launcher tries to open $HOME/service-config/systemd/user/t3code.service, logs ENOENT, and makes zero systemctl --user daemon-reload calls. The control with XDG_CONFIG_HOME unset updates the actual unit to 1.2.4 and calls reload once.
Steps to reproduce#
- Use a clean checkout of commit
59e64eef881632a23f89c4138bcf37f23d92bf93and Node26.12.1. - Run the script below. It imports the unchanged production launcher, creates a committed-update state and installed-unit fixture, and replaces Node's child-process spawn API with event-driven fake children and a fake successful systemctl reload.
- Compare the default service environment with a service-only absolute XDG configuration override. The actual manager unit directory remains the default directory in both fixtures, matching the documented environment.d boundary.
The control passed and the override reproduced the failure in the single run performed. No real server, credentials, user manager, or OS service mutations were used. A native Linux manager run was not performed; the failure evidence is execution of the production launcher with the documented environment boundary supplied as a fixture.
Save this as repro-xdg-unit.mjs outside the checkout and run node repro-xdg-unit.mjs /path/to/checkout. It requires Node with built-in TypeScript stripping and no installed project dependencies, creates all inputs in a temporary directory, redirects HOME, TMPDIR, XDG and T3 state there, removes the directory on exit, and leaves the checkout unchanged. Exit 0 means the expected unit update and reload occurred in both cases; exit 1 means the defect reproduced after a passing control; exit 2 means setup or the control failed.
Reproduction script: repro-xdg-unit.mjs
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { createRequire, syncBuiltinESMExports } from 'node:module';
import { pathToFileURL } from 'node:url';
console.log(`node ${process.version}; ${process.platform}/${process.arch}`);
const checkout = path.resolve(process.argv[2] ?? '.');
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 't3-unit-xdg-'));
const require = createRequire(import.meta.url);
const cp = require('node:child_process');
const originalSpawn = cp.spawn;
const originalWrite = process.stderr.write;
let outcome;
let reloads = 0;
let diagnostics = [];
const clean = value => String(value).replaceAll(scratch, '$FIXTURE');
try {
for (const key of ['HOME', 'USERPROFILE', 'LOCALAPPDATA', 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR', 'T3CODE_HOME', 'TMPDIR']) {
process.env[key] = path.join(scratch, key.toLowerCase());
await fs.mkdir(process.env[key], { recursive: true });
}
cp.spawn = (command, args) => {
const child = new EventEmitter();
child.exitCode = null;
child.signalCode = null;
child.connected = true;
child.kill = signal => {
child.signalCode = signal;
queueMicrotask(() => child.emit('exit', null, signal));
return true;
};
if (command === 'systemctl') {
assert.deepEqual(args, ['--user', 'daemon-reload']);
reloads++;
queueMicrotask(() => { child.exitCode = 0; child.emit('exit', 0, null); outcome.resolve('reloaded'); });
} else {
assert.ok(command.startsWith(scratch + path.sep));
assert.deepEqual(args, ['serve']);
queueMicrotask(() => child.emit('spawn'));
}
return child;
};
process.stderr.write = (chunk, ...args) => {
const text = String(chunk);
if (text.includes('[service-launcher] Could not point ')) {
diagnostics.push(clean(text.trim()));
outcome.resolve('reconcile-error');
return true;
}
return originalWrite.call(process.stderr, chunk, ...args);
};
syncBuiltinESMExports();
const { main } = await import(pathToFileURL(path.join(checkout, 'apps/server/src/serviceLauncher.ts')));
async function runCase(name, serviceXdg) {
const home = path.join(scratch, name);
const baseDir = path.join(home, '.t3');
const unitPath = path.join(home, '.config/systemd/user/t3code.service');
await fs.mkdir(path.dirname(unitPath), { recursive: true });
const runtime = path.join(baseDir, 'runtime/versions/1.2.4');
await fs.mkdir(runtime, { recursive: true });
await fs.writeFile(path.join(runtime, 't3'), 'fixture; not executed\n');
await fs.writeFile(path.join(runtime, '.install-complete'), '1.2.4\n');
await fs.writeFile(path.join(baseDir, 'runtime/service-state.json'), JSON.stringify({
protocol: 4, activeVersion: '1.2.4',
update: { id: 'fixture-update', fromVersion: '1.2.3', targetVersion: '1.2.4', status: 'committed' }
}));
await fs.writeFile(unitPath, `[Service]\nEnvironment=T3CODE_HOME=${baseDir}\nEnvironment=T3_BOOT_SERVICE_UNIT=t3code.service\nExecStart=${baseDir}/runtime/versions/1.2.3/t3 __service-launcher\n`);
process.env.HOME = home;
process.env.T3CODE_HOME = baseDir;
process.env.T3_BOOT_SERVICE_UNIT = 't3code.service';
if (serviceXdg) process.env.XDG_CONFIG_HOME = path.join(home, 'service-config');
else delete process.env.XDG_CONFIG_HOME;
outcome = Promise.withResolvers();
const before = reloads;
const running = main();
let timer;
try {
await Promise.race([
outcome.promise,
running.then(() => { throw new Error('launcher stopped before reconciliation'); }),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('reconciliation timeout')), 5000); })
]);
} finally {
clearTimeout(timer);
process.emit('SIGTERM');
await running;
}
const contents = await fs.readFile(unitPath, 'utf8');
const repointed = contents.includes('/versions/1.2.4/t3 __service-launcher');
console.log(`${name}: unit repointed=${repointed}; daemon-reload calls=${reloads - before}`);
return repointed && reloads > before;
}
assert.equal(await runCase('control', false), true, 'default environment control must reconcile');
const good = await runCase('service-xdg-override', true);
diagnostics.forEach(line => console.log(line));
console.log(good ? 'RESULT: expected behavior' : 'RESULT: defect reproduced');
process.exitCode = good ? 0 : 1;
} catch (error) {
console.log(clean(error.stack));
console.log('RESULT: inconclusive');
process.exitCode = 2;
} finally {
cp.spawn = originalSpawn;
process.stderr.write = originalWrite;
syncBuiltinESMExports();
await fs.rm(scratch, { recursive: true, force: true });
}
Expected behavior#
The launcher should reconcile the actual installed unit after a committed update, even when the environment passed to services overrides XDG_CONFIG_HOME. The systemd 262 environment.d applicability rules specify that these files change the environment passed to managed services without changing the service manager's own environment. The unit load path rules and Manager UnitPath API identify the manager's active search paths. T3's existing unit-reconciliation contract in apps/server/src/serviceLauncher.ts#L83-L94 requires the definition to point at the committed runtime rather than an obsolete one.
Impact#
Minor bug or occasional failure.
Impact: P2 - A valid service environment prevents durable startup-definition reconciliation and its manager reload, leaving the unit on the older runtime while service state selects the newer runtime.
The affected boundary is Linux/systemd launcher unit discovery when the service's absolute XDG_CONFIG_HOME differs from the manager's configuration root, for example through environment.d. The currently selected child still starts in this reproduction; this is not evidence of immediate server downtime. The documented local t3 service install repair rewrites the unit through the separate manager-aware path, but that repair was not run here. NOT_ENOUGH_INFO: native Linux distribution/version reach and subsequent reboot or runtime-pruning effects require a disposable Linux account or VM run.
Version or commit#
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; server package version 0.1.9.
Environment#
Observed with Node v26.12.1 on macOS arm64. The production launcher's POSIX/systemd-unit branch was exercised with fake subprocesses; no systemd runtime was invoked. Upstream contract reference: systemd series 262 documentation, with environment.d source available at published tag v262.2.
Interface#
CLI: private t3 __service-launcher entry point, exercised through its production main() function.
Earliest failure stage#
Setup or configuration: reconstructing the installed unit path from the inherited service environment.
Evidence#
The script's actual output was:
node v26.12.1; darwin/arm64
control: unit repointed=true; daemon-reload calls=1
service-xdg-override: unit repointed=false; daemon-reload calls=0
[service-launcher] Could not point $FIXTURE/service-xdg-override/service-config/systemd/user/t3code.service at t3@1.2.4: ENOENT: no such file or directory, open '$FIXTURE/service-xdg-override/service-config/systemd/user/t3code.service'
RESULT: defect reproduced
$FIXTURE replaces the random temporary directory prefix; the script makes this replacement before printing. apps/server/src/serviceLauncher.ts#L144-L164 reconstructs the unit location from the service environment; apps/server/src/serviceLauncher.ts#L1387-L1407 passes that result into the launcher. apps/server/src/serviceLauncher.ts#L1210-L1248 opens that path before updating or reloading it. In contrast, installation already queries the manager at apps/server/src/cloud/bootService.ts#L547-L579.
Restoration check#
The script should report unit repointed=true; daemon-reload calls=1 for the override as well as the control, then exit 0 with RESULT: expected behavior.
References#
- https://github.com/pingdotgg/t3code/pull/29244 — made installation and CLI lifecycle operations follow the manager's unit directory despite installer-shell XDG differences; this report concerns the separate launcher reconciliation path with service-only environment changes.
- https://github.com/pingdotgg/t3code/pull/19652 — introduced durable unit repointing after committed updates; the reproduction exercises its terminal-state recovery retry using a different inherited service config root.
- https://github.com/pingdotgg/t3code/issues/17490 — historical stale-unit failure; the current report establishes a remaining path-resolution condition after the reconciliation implementation landed, rather than repeating the old absence of reconciliation.
tanstack-source added labelsbug
juliusmarminge closed this
Sign in to join this conversation.