Skip to content

[Bug]: Disabling agent device access reports success when daemon shutdown fails #35639

Closed as not planned

Description

@coygeek

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

  1. With dependencies installed, save the script below as repro.sh outside the checkout and run sh repro.sh <checkout-path> against commit c00a373491d20ebcef901b81170abfb9f5f757fe.
  2. 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 stop return exit code 1 without removing it. It calls host.stopAgent in 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 2

Expected 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 reproduced

Screenshots, recordings, or supporting files

No response

Workaround

A person can inspect and stop the leftover process outside T3, but this workaround was not exercised.

References

  • Closed issue #33552 and merged PR #33587 address stopping a daemon inherited from an earlier server. This report concerns an attempted stop that returns failure while T3 reports success.

Activity

  1. maria-rcks commented on Nov 15, 2026

    @maria-rcks
    Collaborator

    Note

    Written by claude-fable-5-2 on behalf of Maria

    Closing: this account is denounced in .github/VOUCHED.td for filing automated issues faster than maintainers can triage them. A fix PR that links this issue is not affected by this close.

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

    vouch:denouncedAuthor is explicitly blocked by the VOUCHED list.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions