Skip to content

Service runtime 0.0.42 cannot be started by a pre-0.0.42 service-launcher (layout changed to Node SEA; launcher not replaced) #12020

Description

@john-e-west

Summary

Upgrading the background-service runtime to 0.0.42 leaves the service
permanently unable to start. 0.0.42 replaced the JS bundle entrypoint with a
compiled Node SEA binary, but ~/.t3/runtime/service-launcher.mjs is not
replaced by the update and still resolves the old bundle path. The launcher
reports the new runtime as corrupt, systemd restarts it until the start limit is
hit, and the service ends in failed.

The runtime itself is fine. Only the launcher's path assumption is stale.

Impact

On this host the service went from healthy to permanently down with no user
action, and stayed down:

Active: failed (Result: start-limit-hit)
NRestarts=5
:3773 unreachable

Because Restart=always with StartLimitBurst=5, the unit exhausts its budget
in ~5 minutes and then requires a manual systemctl --user reset-failed. It does
not recover on its own. Any deployment that takes 0.0.42 with a pre-0.0.42
launcher on disk will hit this.

Root cause

service-launcher.mjs hardcodes the bundle entrypoint:

const runtimePaths = (baseDir, version) => {
	const versionDir = NodePath.join(baseDir, "runtime", "versions", version);
	return {
		versionDir,
		entryPath: NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
		sentinelPath: NodePath.join(versionDir, ".install-complete")
	};
};

runtimeExists() stats entryPath, fails, and #startChild() throws
Selected t3@${version} runtime is missing or incomplete.

The two layouts differ:

0.0.40 0.0.42
entrypoint node_modules/t3/dist/bin.mjs (8.8 MB JS) ./t3 (compiled ELF, Node SEA)
top level node_modules, package.json, package-lock.json t3, client, resource-monitor, node_modules
node_modules holds the t3 package native addons only (ffi-rs, msgpackr-extract, @ff-labs, detect-libc)

0.0.42/.install-complete correctly contains 0.0.42, and the install is
complete — the sentinel check would pass if the entry path resolved.

The update ships no replacement launcher: nothing matching *launcher*
exists anywhere under versions/0.0.42/.

Reproduction

  1. Run the background service on 0.0.40 (or any bundle-layout release).
  2. Let it update its runtime to 0.0.42.
  3. Service fails to start.
$ journalctl --user -u t3code.service
Starting T3 Code server...
t3code.service: Main process exited, code=exited, status=1/FAILURE
...
$ tail ~/.t3/userdata/logs/boot-service.log
[service-launcher] Selected t3@0.0.42 runtime is missing or incomplete.

Suggested fix

Detect the layout rather than assume it, so one launcher starts either
generation and a rollback to a bundle release still works:

const runtimePaths = (baseDir, version) => {
	const versionDir = NodePath.join(baseDir, "runtime", "versions", version);
	const nativeEntry = NodePath.join(versionDir, "t3");
	const bundleEntry = NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
	let native = false;
	try {
		native = NodeFS.statSync(nativeEntry).isFile();
	} catch {
		native = false;
	}
	return {
		versionDir,
		native,
		entryPath: native ? nativeEntry : bundleEntry,
		sentinelPath: NodePath.join(versionDir, ".install-complete")
	};
};

and at the spawn in #startChild():

const spawnCommand = paths.native ? paths.entryPath : process.execPath;
const spawnArgs    = paths.native ? ["serve"] : [paths.entryPath, "serve"];
const child = NodeChildProcess.spawn(spawnCommand, spawnArgs, { /* unchanged */ });

The "ipc" stdio channel needs no change — the 0.0.42 binary is a Node SEA
(NODE_CHANNEL_FD and node:internal/child_process are present in the image),
so it still speaks the Node IPC protocol, and ./t3 --help shows it accepts
serve.

This fix is written but not yet verified in situ on this host — applying it
requires modifying a live service file and I have not done so. The analysis
above is verified; the patch is not yet proven by a successful start.

Two adjacent notes

A user-side wrapper has the same assumption. Any script that resolves the
active runtime by reading runtime/service-state.json and joining
node_modules/t3/dist/bin.mjs breaks identically. If that pattern is documented
anywhere, it needs the same detection.

Consider making the launcher self-updating or version-gated. The underlying
hazard is that the launcher and the runtime can drift, and the launcher is the
component that cannot be fixed by the update mechanism it supervises. A
minLauncherProtocol field in the runtime manifest — refusing to select a
runtime the on-disk launcher is too old to start, rather than selecting it and
failing — would turn this class of failure into a clean refusal plus a stay on
the previous version.

Environment

t3code-bin      0.0.38-1 (Arch/AUR)
runtimes        0.0.40 (working), 0.0.42 (cannot be started)
launcher mtime  2026-09-12, i.e. the 0.0.40-era file
node            26.8.1 via mise
OS              Arch Linux (Omarchy), kernel 7.1.9, systemd 261
unit            user service, Restart=always, StartLimitBurst=5/300s

Workaround

Roll the active runtime back and reset the failed unit:

printf '{\n  "protocol": 2,\n  "activeVersion": "0.0.40"\n}\n' > ~/.t3/runtime/service-state.json
systemctl --user reset-failed t3code.service
systemctl --user restart t3code.service

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    duplicateThis issue or pull request already exists

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions