Skip to content
Source
Sign inSign up

[Bug]: Linux service launcher loses its unit when environment.d changes XDG_CONFIG_HOME #35943

Send to my agent
Closedcoygeek(GitHub profile, opens in a new tab) opened this issue 2 comments

Summary

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

  1. Save the script below as repro.mjs outside the checkout, then run node --experimental-strip-types repro.mjs /path/to/t3code against the commit in Version.
  2. 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.
  3. It runs a matching-config control, followed by a service environment that models environment.d exporting a different XDG_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.
  1. tanstack-source added labelsbug

  2. Duplicate of #35942: same root cause (the Linux service launcher derives the unit path from its inherited XDG_CONFIG_HOME instead 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.

  3. juliusmarmingecommented

    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.

  4. juliusmarminge closed this

Sign in to join this conversation.