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
- Run the background service on 0.0.40 (or any bundle-layout release).
- Let it update its runtime to 0.0.42.
- 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
Summary
Upgrading the background-service runtime to 0.0.42 leaves the service
permanently unable to start.
0.0.42replaced the JS bundle entrypoint with acompiled Node SEA binary, but
~/.t3/runtime/service-launcher.mjsis notreplaced 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:
Because
Restart=alwayswithStartLimitBurst=5, the unit exhausts its budgetin ~5 minutes and then requires a manual
systemctl --user reset-failed. It doesnot 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.mjshardcodes the bundle entrypoint:runtimeExists()statsentryPath, fails, and#startChild()throwsSelected t3@${version} runtime is missing or incomplete.The two layouts differ:
node_modules/t3/dist/bin.mjs(8.8 MB JS)./t3(compiled ELF, Node SEA)node_modules,package.json,package-lock.jsont3,client,resource-monitor,node_modulesnode_modulesholdst3packageffi-rs,msgpackr-extract,@ff-labs,detect-libc)0.0.42/.install-completecorrectly contains0.0.42, and the install iscomplete — 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
Suggested fix
Detect the layout rather than assume it, so one launcher starts either
generation and a rollback to a bundle release still works:
and at the spawn in
#startChild():The
"ipc"stdio channel needs no change — the 0.0.42 binary is a Node SEA(
NODE_CHANNEL_FDandnode:internal/child_processare present in the image),so it still speaks the Node IPC protocol, and
./t3 --helpshows it acceptsserve.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.jsonand joiningnode_modules/t3/dist/bin.mjsbreaks identically. If that pattern is documentedanywhere, 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
minLauncherProtocolfield in the runtime manifest — refusing to select aruntime 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
Workaround
Roll the active runtime back and reset the failed unit: