[Bug]: Linux service launcher loses its unit when environment.d changes XDG_CONFIG_HOME #35943
Send to my agentSummary#
A Linux background service can start successfully but stop reconciling its installed unit when its inherited config directory differs from the systemd manager's own config directory. The launcher then leaves the unit on its previous runtime and cannot find the log to bound its size. A fixture running the real launcher entry point reproduced both failures with fake subprocesses; the matching-directory control succeeded. The launcher should continue using the unit that actually started it.
Before submitting#
- Related issues: related history is listed under Additional context; no current exact duplicate found in open and closed issue and PR searches.
- Removed credentials, tokens, private paths, and sensitive data.
Area#
apps/server
Actual behavior#
The real serviceLauncher.main() uses the service process's XDG_CONFIG_HOME to locate t3code.service. Given a unit installed under $HOME/.config/systemd/user and a service environment with XDG_CONFIG_HOME=$HOME/service-config, terminal update recovery looks for $HOME/service-config/systemd/user/t3code.service, reports the failed reconciliation, leaves the actual unit's ExecStart on the previous version, performs no daemon reload, and skips bounding the oversized log. The same fixture with matching config roots repoints the unit, reloads the fake manager, and empties the oversized log in place.
Steps to reproduce#
- Save the script below as
repro.mjsoutside the checkout, then runnode --experimental-strip-types repro.mjs /path/to/t3codeagainst the commit in Version. - The script creates disposable home, config, state, and temporary directories, an installed unit fixture, a valid committed update state, and an oversized log. It invokes the real launcher
main()and replaces only subprocess spawning with in-memory fixtures, following the existing launcher's process-test boundary. - It runs a matching-config control, followed by a service environment that models
environment.dexporting a differentXDG_CONFIG_HOME. No service manager, provider, server, or executable fixture runs.
Both cases ran once, with deterministic fixture results. This reproduces T3's reaction to the documented environment difference, not a native Linux environment generator or live manager lifecycle.
Prerequisite: a supported Node version with TypeScript stripping; no dependency installation is needed. The script takes the checkout as its only argument, prints Node's version and platform, creates all inputs under a temporary directory, restores its environment and spawn mock, deletes its fixtures, and leaves the checkout unchanged. Exit 1 means the defect was reproduced, exit 0 means both cases meet the contract, and exit 2 means the setup or control was inconclusive.
Reproduction script: repro.mjs
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import cp from 'node:child_process';
import { EventEmitter } from 'node:events';
import { syncBuiltinESMExports } from 'node:module';
import { pathToFileURL } from 'node:url';
const checkout = path.resolve(process.argv[2] ?? '.');
console.log(`Node ${process.version}; ${process.platform}/${process.arch}`);
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 't3-unit-env-'));
const savedEnv = { ...process.env };
const originalSpawn = cp.spawn;
let reloads = 0;
let fakeChildren = 0;
cp.spawn = (command, args) => {
const child = new EventEmitter();
child.exitCode = null;
child.signalCode = null;
child.connected = false;
child.kill = () => true;
if (command === 'systemctl' && args.join(' ') === '--user daemon-reload') {
reloads++;
queueMicrotask(() => child.emit('exit', 0, null));
} else if (command.endsWith('/runtime/versions/1.2.4/t3')) {
fakeChildren++;
queueMicrotask(() => {
child.emit('spawn');
setImmediate(() => {
child.exitCode = 0;
child.emit('exit', 0, null);
});
});
} else throw new Error('Unexpected subprocess');
return child;
};
syncBuiltinESMExports();
let status = 2;
try {
const { main } = await import(pathToFileURL(path.join(checkout, 'apps/server/src/serviceLauncher.ts')));
async function runCase(name, divergent) {
const home = path.join(root, name);
const base = path.join(home, '.t3');
const config = path.join(home, '.config');
const serviceConfig = path.join(home, 'service-config');
const runtime = path.join(base, 'runtime');
const unitPath = path.join(config, 'systemd/user/t3code.service');
const logPath = path.join(base, 'userdata/logs/boot-service.log');
await fsp.mkdir(path.dirname(unitPath), { recursive: true });
await fsp.mkdir(path.dirname(logPath), { recursive: true });
await fsp.mkdir(path.join(runtime, 'versions/1.2.4'), { recursive: true });
await fsp.writeFile(path.join(runtime, 'versions/1.2.4/t3'), 'fixture (spawn is mocked)');
await fsp.writeFile(path.join(runtime, 'versions/1.2.4/.install-complete'), '1.2.4\n');
await fsp.writeFile(path.join(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 fsp.writeFile(unitPath, [
'[Service]', `Environment=T3CODE_HOME=${base}`,
'Environment=T3_BOOT_SERVICE_UNIT=t3code.service',
`ExecStart=${base}/runtime/versions/1.2.3/t3 __service-launcher`,
`StandardOutput=append:${logPath}`, `StandardError=append:${logPath}`, ''
].join('\n'));
await fsp.writeFile(logPath, '');
await fsp.truncate(logPath, 10 * 1024 * 1024 + 1);
const temp = path.join(home, 'tmp');
await fsp.mkdir(temp, { recursive: true });
Object.assign(process.env, {
HOME: home, USERPROFILE: home, LOCALAPPDATA: path.join(home, 'local'),
XDG_CONFIG_HOME: divergent ? serviceConfig : config,
XDG_RUNTIME_DIR: path.join(home, 'run'), XDG_DATA_HOME: path.join(home, 'data'),
XDG_STATE_HOME: path.join(home, 'state'), XDG_CACHE_HOME: path.join(home, 'cache'),
T3CODE_HOME: base, T3_BOOT_SERVICE_UNIT: 't3code.service', TMPDIR: temp
});
const before = reloads;
const originalWrite = process.stderr.write;
let diagnostics = '';
process.stderr.write = (chunk) => { diagnostics += String(chunk); return true; };
try {
await main().then(() => { throw new Error('Expected fixture child exit'); }, error => {
if (!String(error).includes('Active child exited unexpectedly (0)')) throw error;
});
} finally { process.stderr.write = originalWrite; }
const unit = await fsp.readFile(unitPath, 'utf8');
const repointed = unit.includes('/runtime/versions/1.2.4/t3 __service-launcher');
const bounded = (await fsp.stat(logPath)).size === 0;
const reloaded = reloads > before;
const wrongLookup = diagnostics.includes('/service-config/systemd/user/t3code.service');
console.log(`${name}: repointed=${repointed} reloaded=${reloaded} logBounded=${bounded} wrongUnitLookup=${wrongLookup}`);
return { repointed, bounded, reloaded, wrongLookup };
}
const control = await runCase('control', false);
if (!control.repointed || !control.bounded || !control.reloaded) throw new Error('Control failed');
const divergent = await runCase('environment-d-XDG', true);
if (divergent.repointed && divergent.bounded && divergent.reloaded) status = 0;
else if (!divergent.repointed && !divergent.bounded && !divergent.reloaded && divergent.wrongLookup && fakeChildren === 2) status = 1;
} catch (error) {
console.log(`Setup/control error: ${String(error).replaceAll(root, '$SCRATCH')}`);
} finally {
cp.spawn = originalSpawn;
syncBuiltinESMExports();
process.env = savedEnv;
await fsp.rm(root, { recursive: true, force: true });
}
console.log(status === 0 ? 'RESULT: expected behavior' : status === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = status;
Expected behavior#
A service should reconcile the unit found by the manager that launched it, even when environment generators give that service a different XDG_CONFIG_HOME. The systemd 262 environment.d Applicability contract distinguishes variables exported to services from the manager's own environment; those assignments do not change the manager's behavior or unit search root. The installer already queries the manager's UnitPath in apps/server/src/cloud/bootService.ts#L547-L579. The unit contract says its environment marker lets the launcher find the definition that started it in apps/server/src/cloud/serviceProtocol.ts#L12-L15, and terminal update recovery explicitly repoints that definition in apps/server/src/serviceLauncher.ts#L991-L1004.
Impact#
Minor bug or occasional failure.
Impact: P2 - This is a bounded service-management defect for Linux users whose service config environment differs from the manager's config root. The fixture directly establishes stale unit content and skipped log bounding, while the selected active child can still start. It does not establish an actual service outage, data loss, or disk exhaustion.
The matching-root control demonstrates that aligning the service's inherited config root with the manager's root avoids this failure. Whether that adjustment is practical for a particular user's other services is unknown. Native Linux behavior and full remote-update UI execution were not run; the exported environment boundary is modeled from the systemd documentation. The same resolver also feeds log lookup, so the log symptom is a consequence of this one unit-identity failure rather than a separate rotation-policy report.
Version or commit#
T3 Code package version 0.1.9; commit 59e64eef881632a23f89c4138bcf37f23d92bf93.
Environment#
Node v26.12.1; reproduction host darwin/arm64. The target is Linux systemd user services; the expected environment semantics were checked against the released systemd 262 documentation and v262.2 source. No Linux manager version was executed.
Interface#
Package or setup: Linux background-service launcher, terminal update recovery, and installed-unit reconciliation.
Earliest failure stage#
Setup or configuration
Evidence#
Observed output from the script above, with no private paths in its output:
Node v26.12.1; darwin/arm64
control: repointed=true reloaded=true logBounded=true wrongUnitLookup=false
environment-d-XDG: repointed=false reloaded=false logBounded=false wrongUnitLookup=true
RESULT: defect reproduced
The resolver derives the unit directory from inherited XDG_CONFIG_HOME at apps/server/src/serviceLauncher.ts#L126-L151. main() snapshots that environment and passes it to the resolver before configuring log bounding at apps/server/src/serviceLauncher.ts#L1388-L1413. A missing guessed unit makes serviceLogPath() return undefined at apps/server/src/serviceLauncher.ts#L216-L225; terminal recovery subsequently attempts reconciliation against that same path at apps/server/src/serviceLauncher.ts#L1210-L1249.
References#
- https://github.com/pingdotgg/t3code/pull/29244 — corrects installer/status unit discovery using the manager's UnitPath; it changes bootService.ts, while the launcher still reconstructs the path from the service environment.
- https://github.com/pingdotgg/t3code/pull/19652 — adds unit reconciliation after remote commits and during terminal recovery; the fixture exercises its recovery path with a valid committed state.
- https://github.com/pingdotgg/t3code/issues/24755 — covers the original missing log bound; this report concerns the new launcher's inability to locate the installed unit and therefore its configured log under differing service and manager config roots.
tanstack-source added labelsbug
coygeek(GitHub profile, opens in a new tab) closed this
This is the same problem as #35942, so I'm closing it in favour of that one. If it is different, say how and it will be reopened.
juliusmarminge closed this
Sign in to join this conversation.
Duplicate of #35942: same root cause (the Linux service launcher derives the unit path from its inherited
XDG_CONFIG_HOMEinstead of the manager's unit directory). The two reports were filed concurrently by parallel investigation lanes. This report adds one further consequence of that root cause: the launcher also cannot locate the service log, so log bounding is skipped.