Repository navigation
Description
Summary
Turning off agent device access can report success while its local daemon remains running if the shutdown command fails. The setting then shows access as off even though the helper that T3 owns was not stopped. A focused fixture reproduced the unchecked failure at the stated commit. Impact: P2, because the failure is limited to daemon shutdown errors but can leave a background helper and device claims behind.
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
- With dependencies installed, save the script below as
repro.shoutside the checkout and runsh repro.sh <checkout-path>against commitc00a373491d20ebcef901b81170abfb9f5f757fe. - The script creates an isolated LocalDeviceHost fixture with a live recorded PID. Its control makes a successful stop remove the record; its failing case makes
daemon stopreturn exit code 1 without removing it. It callshost.stopAgentin both cases.
The failing case reproduced on one run after the control passed. The fixture models the stop CLI and record; it does not start a real agent-device daemon. The script requires Node, vp, and the checkout's installed dependencies. It creates a uniquely named temporary test under the checkout, puts home, temporary files, and T3 state under a disposable directory, removes its test and scratch on exit, and prints one RESULT line. Exit 0 means the stop failure was surfaced or the record was removed; exit 1 means the defect reproduced; exit 2 means setup or the control was inconclusive.
Reproduction script: repro.sh
#!/bin/sh
set -u
checkout=${1:?pass checkout path}
scratch=$(mktemp -d)
testfile="$checkout/apps/server/src/device/StopDaemon.x04-01.test.ts"
cleanup() { rm -f "$testfile"; rm -rf "$scratch"; }
if test -e "$testfile"; then
printf 'RESULT: inconclusive\n'
exit 2
fi
trap cleanup EXIT HUP INT TERM
mkdir -p "$scratch/home" "$scratch/tmp" "$scratch/state"
export HOME="$scratch/home" TMPDIR="$scratch/tmp" T3CODE_HOME="$scratch/state"
printf 'node: '; node --version
printf 'vp: '; vp --version
cat > "$testfile" <<'EOF'
import { it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as FileSystem from "effect/FileSystem";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import { HttpClient, HttpClientResponse } from "effect/http";
import * as NetService from "@t3tools/shared/Net";
import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as LocalDeviceHost from "./LocalDeviceHost.ts";
import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION, agentDeviceStateDir } from "./DeviceToolchain.ts";
const output = (code: number) => ({
code: ChildProcessSpawner.ExitCode(code), stdout: "", stderr: "", timedOut: false,
stdoutTruncated: false, stderrTruncated: false,
stdoutInvalidUtf8: false, stderrInvalidUtf8: false,
});
it.effect("x04-01 reports a daemon stop command that fails", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-x04-01-" });
const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata"));
const daemonFile = path.join(stateDir, "daemon.json");
const runs: Array<ProcessRunner.ProcessRunInput> = [];
let stopCode = 0;
for (const [name, version, entry] of [
["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"],
["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"],
]) {
const dir = path.join(baseDir, "tools", name!, version!);
const file = path.join(dir, "node_modules", name!, entry!);
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
yield* fs.writeFileString(file, "");
yield* fs.writeFileString(path.join(dir, ".install-complete"), version!);
}
yield* fs.makeDirectory(stateDir, { recursive: true });
yield* fs.writeFileString(daemonFile, JSON.stringify({
httpPort: 12345, token: "fixture-token", pid: process.pid, version: AGENT_DEVICE_VERSION,
}));
const host = yield* LocalDeviceHost.make().pipe(
Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)),
Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }),
Effect.provideService(HostProcess.IsExecutable, false),
Effect.provideService(HostProcess.Platform, "darwin"),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.succeed(ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(123), stdout: Stream.empty, stderr: Stream.empty,
all: Stream.empty, exitCode: Effect.never, isRunning: Effect.succeed(true),
kill: () => Effect.void, stdin: Sink.drain, getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty, unref: Effect.succeed(Effect.void),
}))),
),
Effect.provideService(ProcessRunner.ProcessRunner, {
run: (input) => {
runs.push(input);
if (!input.args.includes("daemon")) return Effect.succeed(output(0));
return Effect.gen(function* () {
if (stopCode === 0) yield* fs.remove(daemonFile, { force: true });
return output(stopCode);
});
},
}),
Effect.provideService(HttpClient.HttpClient, HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok", { status: 200 }))),
)),
);
yield* host.ensureReady(() => Effect.void);
const control = yield* host.stopAgent.pipe(Effect.result);
if (control._tag !== "Success" || (yield* fs.exists(daemonFile))) {
throw new Error("INCONCLUSIVE_SUCCESSFUL_STOP_CONTROL");
}
yield* fs.writeFileString(daemonFile, JSON.stringify({
httpPort: 12345, token: "fixture-token", pid: process.pid, version: AGENT_DEVICE_VERSION,
}));
stopCode = 1;
const result = yield* host.stopAgent.pipe(Effect.result);
if (!runs.some((run) => run.args.includes("daemon") && run.args.includes("stop"))) {
throw new Error("INCONCLUSIVE_NO_STOP_COMMAND");
}
if (result._tag === "Success" && (yield* fs.exists(daemonFile))) {
throw new Error("DEFECT_REPRODUCED: failed daemon stop reported success with daemon record intact");
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
EOF
cd "$checkout" || { printf 'RESULT: inconclusive\n'; exit 2; }
vp test run apps/server/src/device/StopDaemon.x04-01.test.ts > "$scratch/output" 2>&1
test_status=$?
cat "$scratch/output"
if test "$test_status" -eq 0; then
printf 'RESULT: expected behavior\n'
exit 0
fi
if grep -q 'DEFECT_REPRODUCED: failed daemon stop reported success with daemon record intact' "$scratch/output"; then
printf 'RESULT: defect reproduced\n'
exit 1
fi
printf 'RESULT: inconclusive\n'
exit 2Expected behavior
When agent access is turned off, its helper should be stopped, or T3 should report that shutdown failed. The device design states that agent-device remains stopped until access is granted (docs/internals/devices.md:10-15); the user guide says turning off device support stops helper processes (docs/user/devices.md:25-27). A successful response with the daemon still recorded as live contradicts that lifecycle contract.
Actual behavior
In a focused LocalDeviceHost fixture, the agent-device stop command returned exit code 1 and left the daemon record in place. host.stopAgent still completed successfully. The supported device.configure path stores agentAccessEnabled: false before calling each host's stopAgent, then publishes the disabled state when the call succeeds. Thus a user can see agent access turned off despite a failed attempt to stop its daemon.
Impact and scope
Impact: P2 - a daemon shutdown error is a bounded failure, but T3 can report a disabled setting while leaving its background helper running. The server starts this daemon with its idle timeout disabled, so it does not retire merely because the setting changed.
The reproduced path is the local host with a nonzero stop-command result. The source also drops a timed-out result, and returns successfully when no installed agent-device entry is found despite a live recorded PID; those variants were not run. A person can inspect and stop the leftover process outside T3, but that workaround was not exercised. Whether any retained device claim blocks a later session was not measured.
Earliest failure stage
Cleanup or lifecycle
Analysis or suspected cause
Source trace: the local shutdown wrapper discards both a successful process execution's nonzero exit status and any effect failure. The server therefore cannot distinguish the fixture's failed stop from a completed stop. This identifies the missing failure signal; it does not establish why a real agent-device CLI might fail.
Additional context
Closed issue #33552 and merged PR #33587 address stopping a daemon inherited from an earlier server, not handling an attempted stop that fails. Open PR #33566 changes shutdown ordering when the hub is slow; open PR #33880 handles a missed health probe during daemon startup. Neither checks this stopAgent result. The SSH stop script also discards its own stop result at apps/server/src/device/sshDeviceScript.ts:161-168; that surface was inspected in source but not reproduced.
At the tested commit, apps/server/src/device/LocalDeviceHost.ts:649-665 invokes daemon stop with timeoutBehavior: "timedOutResult", never checks the returned exit code or timeout flag, and ends with Effect.ignore. LocalDeviceHost.ts:861-867 then clears the cached endpoint after that effect succeeds. apps/server/src/device/DeviceService.ts:705-738 writes the disabled setting, calls stopAgent, and publishes agentAccessEnabled: false. The latest main commit touching LocalDeviceHost.ts, 1a2a4a5e9684585c31eb55c1974fb32f8b1db2b7, is already an ancestor of the tested checkout.
Affected interface: Devices settings, through the device.configure WebSocket method and the local DeviceHost.
Impact
Minor bug or occasional failure
Version or commit
main @ c00a373
Environment
Darwin arm64 fixture; Node v26.12.1; vp v1.5.1; vite-plus v1.4.0; Vitest v5.0.5; effect 4.1.2.
Logs or stack traces
The successful-stop control passed before the failing case. The test-runner stack paths were omitted from this excerpt.
node: v26.12.1
vp: vp v1.5.1
Error: DEFECT_REPRODUCED: failed daemon stop reported success with daemon record intact
Test Files 1 failed (1)
RESULT: defect reproducedScreenshots, recordings, or supporting files
No response
Workaround
A person can inspect and stop the leftover process outside T3, but this workaround was not exercised.
Activity
maria-rcks commentedon Nov 15, 2026
Note
Written by
claude-fable-5-2on behalf of MariaClosing: this account is denounced in
.github/VOUCHED.tdfor filing automated issues faster than maintainers can triage them. A fix PR that links this issue is not affected by this close.