Skip to content
Source
Sign inSign up

[Bug]: macOS service launcher trims a trailing space from the selected T3 home #35946

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

Summary

A macOS service configured to use a data directory whose final component ends in a space cannot start its selected server. Its plist retains the right path, but the launcher removes the space and looks for startup state in another directory. This was reproduced with the real launcher and disposable runtime fixtures; it should retain the selected directory exactly.

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Save the script below as repro-home-space.mjs outside the checkout, then run node repro-home-space.mjs /path/to/t3code at the stated revision on macOS.
  2. Observe the ordinary-path control start and stop cleanly, then the trailing-space fixture fail before its server starts despite a valid, path-preserving plist.
  3. Observe the symlink alias to that same trailing-space fixture start and stop cleanly.

One isolated script run reproduced this failure. It is deterministic in the checked path: the final space is removed before the state-file read.

Prerequisites: macOS with its bundled plutil and sw_vers, Node.js with native TypeScript stripping and node:module.stripTypeScriptTypes (the run used Node 26), and a clean checkout of the stated revision. No repository dependencies are required. The script extracts the actual pure renderer, imports the actual standalone launcher, creates its inputs with mkdtempSync, isolates home/config/runtime/temp variables for every child, and removes its fixtures on exit. The fake runtime prints a startup marker and handles SIGTERM; it does not run a T3 server, open a database, use credentials, or contact an OS service manager. It removes T3_BOOT_SERVICE_UNIT before launcher execution so no registered service definition is discovered or changed. Exit 1 means defect reproduced, 0 means the selected fixture starts and stops correctly, and 2 means setup or the control was inconclusive.

Reproduction script: repro-home-space.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { stripTypeScriptTypes } from 'node:module';
import { spawn, spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';

const checkout = process.argv[2];
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 't3-home-space-'));
const environment = {...process.env};
for (const key of ['HOME', 'USERPROFILE', 'LOCALAPPDATA', 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', 'TMPDIR']) {
  environment[key] = path.join(scratch, key);
  fs.mkdirSync(environment[key]);
}
delete environment.T3_BOOT_SERVICE_UNIT;
delete environment.T3_SERVICE_LAUNCHER_CONTEXT;
let result = 2;
try {
  if (!checkout || process.platform !== 'darwin') throw new Error('Requires a T3 Code checkout and macOS');
  console.log(`Node ${process.version}`);
  const osVersion = spawnSync('/usr/bin/sw_vers', [], {encoding:'utf8'});
  if (osVersion.status !== 0) throw new Error('Cannot determine OS version');
  console.log(osVersion.stdout.trim());
  console.log('plutil: /usr/bin/plutil bundled with the OS above (no separate version flag)');

  // Extract the actual pure renderer without importing its Effect dependencies.
  const source = fs.readFileSync(path.join(checkout, 'apps/server/src/cloud/bootService.ts'), 'utf8');
  const start = source.indexOf('function escapeXmlText(');
  const end = source.indexOf('export function renderBootServiceTask(');
  if (start < 0 || end <= start) throw new Error('Renderer source boundaries changed');
  const context = vm.createContext({BOOT_SERVICE_LAUNCHD_LABEL:'com.t3tools.t3code.service',BOOT_SERVICE_PLIST_FILE:'com.t3tools.t3code.service.plist',SERVICE_UNIT_ENV:'T3_BOOT_SERVICE_UNIT'});
  vm.runInContext(`${stripTypeScriptTypes(source.slice(start,end).replaceAll('export ',''))}\nglobalThis.render = renderBootServicePlist;`, context);

  const fixture = baseDir => {
    const versionDir = path.join(baseDir,'runtime','versions','1.0.0');
    fs.mkdirSync(versionDir,{recursive:true});
    const fakeRuntime = path.join(versionDir,'t3');
    fs.writeFileSync(fakeRuntime, `#!${process.execPath}\nprocess.stdout.write('FIXTURE_STARTED\\n');\nprocess.once('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n`, {mode:0o700});
    fs.writeFileSync(path.join(versionDir,'.install-complete'),'1.0.0\n');
    fs.writeFileSync(path.join(baseDir,'runtime','service-state.json'),JSON.stringify({protocol:4,activeVersion:'1.0.0'}));
    return fakeRuntime;
  };
  const launch = async (name, baseDir) => {
    const unitPath = path.join(scratch,`${name}.plist`);
    fs.writeFileSync(unitPath, context.render({program:[path.join(baseDir,'runtime','versions','1.0.0','t3'),'__service-launcher'],baseDir,logPath:path.join(scratch,'log'),unitPath},{homeDir:environment.HOME,environmentPath:environment.PATH ?? '/usr/bin:/bin'}));
    const lint = spawnSync('/usr/bin/plutil',['-lint',unitPath],{encoding:'utf8',env:environment});
    const converted = spawnSync('/usr/bin/plutil',['-convert','json','-o','-',unitPath],{encoding:'utf8',env:environment});
    if (lint.status !== 0 || converted.status !== 0) throw new Error(`${name}: plist validation failed`);
    const plist = JSON.parse(converted.stdout);
    if (plist.EnvironmentVariables.T3CODE_HOME !== baseDir) throw new Error(`${name}: plist changed T3CODE_HOME`);
    const env = {...environment,...plist.EnvironmentVariables};
    // Do not ask the launcher to discover or modify a registered service definition.
    delete env.T3_BOOT_SERVICE_UNIT;
    const moduleUrl = pathToFileURL(path.resolve(checkout,'apps/server/src/serviceLauncher.ts')).href;
    const driver = `import {main} from ${JSON.stringify(moduleUrl)}; await main();`;
    let output = '';
    let error = '';
    let started = false;
    const child = spawn(process.execPath,['--input-type=module','--eval',driver],{env,cwd:scratch,stdio:['ignore','pipe','pipe']});
    const timer = setTimeout(() => child.kill('SIGKILL'),10000);
    const exit = await new Promise((resolve,reject) => {
      child.once('error',reject);
      child.stdout.on('data',data => {
        output += data.toString();
        if (!started && output.includes('FIXTURE_STARTED\n')) {
          started=true;
          child.kill('SIGTERM');
        }
      });
      child.stderr.on('data',data => {error += data.toString();});
      child.once('close',(code,signal) => resolve({code,signal}));
    }).finally(() => clearTimeout(timer));
    const lookedInTrimmedHome = error.includes("ENOENT") && error.includes(path.join(baseDir.trim(),'runtime','service-state.json'));
    console.log(`${name}: plist lint=0; home preserved=true; fixture started=${started}; exit=${exit.code}; signal=${exit.signal}; read trimmed home=${lookedInTrimmedHome}`);
    return {started,...exit,lookedInTrimmedHome};
  };

  const normal = path.join(scratch,'ordinary-home');
  fixture(normal);
  const control = await launch('control',normal);
  if (!control.started || control.code !== 0) throw new Error('Control did not start and stop cleanly');

  // CLI resolveBaseDir trims the raw input, then path.resolve removes this final slash.
  // The directory name's trailing space survives that supported input path.
  const raw = path.join(scratch,'home-with-space ') + '/';
  const baseDir = path.resolve(raw.trim());
  fixture(baseDir);
  console.log(`CLI input ends in slash=${raw.endsWith('/')}; resolved home ends in space=${baseDir.endsWith(' ')}`);
  const failing = await launch('trailing-space',baseDir);

  const alias = path.join(scratch,'home-alias');
  fs.symlinkSync(baseDir,alias,'dir');
  if (fs.realpathSync(alias) !== fs.realpathSync(baseDir)) throw new Error('Alias targets another fixture');
  const workaround = await launch('symlink-workaround',alias);
  if (!workaround.started || workaround.code !== 0) throw new Error('Workaround did not preserve the same home');

  result = failing.started && failing.code === 0 ? 0 : !failing.started && failing.code === 1 && failing.lookedInTrimmedHome ? 1 : 2;
} catch (error) {
  console.log(`Setup/check error: ${error.message}`);
} finally {
  fs.rmSync(scratch,{recursive:true,force:true});
}
console.log(result===0 ? 'RESULT: expected behavior' : result===1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode=result;

Expected behavior

The launcher should read startup state and start the selected runtime under the exact T3 home supplied in the plist's string environment variable. Directory-name whitespace must not change that identity. Apple's launchd.plist(5) describes EnvironmentVariables values as the desired strings; see the Apple manual source. The locally installed manual on the tested OS gives the same string-value contract. T3's flag describes --base-dir as the explicit data directory equivalent to T3CODE_HOME at apps/server/src/cli/config.ts#L44-L49, and its renderer places the plan's exact base directory in that variable at apps/server/src/cloud/bootService.ts#L161-L177.

Actual behavior

The actual serviceLauncher.main() receives a T3CODE_HOME ending in one ASCII space from the parsed output of renderBootServicePlist. Although /usr/bin/plutil -lint accepts the plist and its parsed environment preserves the whole path, the launcher reads runtime/service-state.json under the version of the path without that space. It exits with code 1 and ENOENT; the disposable runtime in the selected directory never starts. An ordinary-path control and a symlink alias pointing to the same spaced directory both start their fixture runtime and stop cleanly with code 0.

The supported CLI input can end in a slash after the space: t3 service install --base-dir "$HOME/T3 data /". In the inspected source, resolveBaseDir trims the raw string before path.resolve, so the slash protects the component's trailing space until path normalization removes the slash. The generated service plan then contains the actual directory ending in a space. That live installation command was not run; the script below reproduces its normalized path, invokes the actual renderer, parses the plist with Apple's tool, and imports the actual standalone launcher.

Impact

Minor bug or occasional failure

Impact: P2 - The launcher cannot start the selected service home for a valid, bounded class of directory names. A symlink alias without trailing whitespace was verified to start the same fixture home, providing a practical workaround without moving its contents.

Observed on macOS when the final T3-home directory component ends in one ASCII space and the shortened sibling directory is absent. The failure occurs on each invocation for that input. The standalone launcher entry point is shared with Linux, but Linux was not executed; Windows path rules were not tested. A second existing home at the shortened path was not tested, and no claim of data loss is made. The symlink workaround was verified at the launcher boundary, with a fake runtime, not through real service registration or a running T3 server.

Version or commit

Server package 0.1.9, source commit 59e64eef881632a23f89c4138bcf37f23d92bf93. The exact source checkout was used; no packaged desktop app or installed service was exercised.

Environment

macOS 27.2 build 26B123; Node.js v26.12.1; /usr/bin/plutil and /usr/bin/sw_vers bundled with that OS. Node's experimental TypeScript-stripping warning appeared during the run. No external runtime executable was downloaded: the disposable fixture uses the same Node executable that runs the reproduction.

Logs or stack traces

Evidence

Output from the isolated script run, exit 1; the standard experimental-warning footer is omitted below. All displayed path labels are fixture roles, and no private machine paths or identities appear.

Node v26.12.1
ProductName: macOS
ProductVersion: 27.2
BuildVersion: 26B123
plutil: /usr/bin/plutil bundled with the OS above (no separate version flag)
control: plist lint=0; home preserved=true; fixture started=true; exit=0; signal=null; read trimmed home=false
CLI input ends in slash=true; resolved home ends in space=true
trailing-space: plist lint=0; home preserved=true; fixture started=false; exit=1; signal=null; read trimmed home=true
symlink-workaround: plist lint=0; home preserved=true; fixture started=true; exit=0; signal=null; read trimmed home=false
RESULT: defect reproduced

The relevant code is apps/server/src/serviceLauncher.ts#L1377-L1397: the first line trims the received base directory, the manager environment is rewritten with that shortened value, and the launcher reads state from the shortened path. The supported normalization path is apps/server/src/os-jank.ts#L232-L238; the CLI selects the explicit flag/environment value at apps/server/src/cli/config.ts#L333-L342. The selected base directory is used during pinned-runtime installation at apps/server/src/cloud/bootService.ts#L1215-L1223.

Evidence provenance: observed execution of the actual renderer and launcher, plus static tracing of the CLI normalization path. Local verification: reproduced. Reproduction completeness: complete for the path-changing startup failure at the launcher boundary. Missing fact: NOT_ENOUGH_INFO no launchd job was loaded, so the installed service's repeated-restart behavior and a full real-server startup were not observed; those require an isolated OS account or VM and a real service runtime.

Interface: Package or setup: macOS background service plist environment and standalone __service-launcher entry point.

Earliest failure stage: Setup or configuration.

Expected source: Apple string-environment semantics and the T3 CLI/renderer source linked above. Failure source: execution of the actual standalone launcher.

Workaround

A symlink alias without trailing whitespace pointing to the same directory starts and stops the same fixture successfully. This was verified at the launcher boundary; no real service was installed.

Restoration check

Run the included script against the candidate change. The plist must retain the exact trailing-space home, that fixture must start and stop cleanly like the control and alias, and the script must print RESULT: expected behavior with exit 0. The real service must continue to use that same chosen T3 home throughout startup.

References

The report concerns the identity of the service's already-resolved T3 home. It is distinct from repository identity lookup, project-file read/write, external-volume log permissions, versioned executable privacy permissions, file-descriptor limits, and PATH after self-update.

  • https://github.com/pingdotgg/t3code/issues/35747 — related whitespace-path history in repository identity resolution; its resolver and acceptance criterion do not cover service-launcher startup.
  • https://github.com/pingdotgg/t3code/issues/29313 — related whitespace-path history in project-file read/write; it does not cover the launcher reading service state from another T3 home.
  1. tanstack-source added labelsbug

  2. juliusmarminge closed this

Sign in to join this conversation.