From d808c38daf644f9821fb9d7a6babb3acf4f49a5f Mon Sep 17 00:00:00 2001 From: Carl Tashian Date: Mon, 4 May 2026 20:25:38 -0700 Subject: [PATCH] feat(tsmd): idle-quit daemon when locked and silent for 30 minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the staleness-detection logic in the npm shim (#12) with a self-contained lifecycle on the daemon. The daemon shuts itself down when its vault is locked AND no client has sent an RPC for the idle window (default 30 minutes). The next `tsm` command spawns a fresh daemon from whatever binary is on disk, so an upgrade is picked up automatically once the user has been idle long enough — no shim heuristics, no postinstall scripts, no path comparisons. The shim-based approach had a real correctness gap: bun's `bun install -g` overwrites the binary in place, so the running tsmd's argv[0] matched the post-upgrade path and the staleness check left it alone. Idle-quit covers the in-place case, the cross-prefix case, and any future install method without a wrapper-side opinion. Tradeoff: between an upgrade and the idle window, clients keep talking to the old code. `tsm daemon stop` is the escape hatch when that matters. Implementation: - New IdleTracker actor (tracks lastActivityAt, isIdle predicate). - JSONRPCHandler bumps the tracker on every request. - Daemon owns the tracker, exposes shouldIdleQuit(), and the existing 15 s tick now also evaluates the idle predicate and posts .tsmdShutdown when it fires. - Daemon.init grew injectable vault/idleTracker/idleQuitSeconds/ tickInterval parameters so tests can drive Daemon.run() to completion in milliseconds. Tests: IdleTrackerTests (6), DaemonIdleQuitTests (5, including a real Daemon.run() round-trip that confirms the timer → notification → shutdown chain), plus two JSONRPCHandler bump tests. Existing 110 tests still pass; total 123. Reverts the shim-side staleness logic from #12 — shim.js is back to resolving the platform package and exec'ing tsm, nothing else. --- RELEASING.md | 2 +- npm/wrapper/README.md | 2 +- npm/wrapper/scripts/shim.test.js | 180 ------------------ npm/wrapper/shim.js | 163 ++++------------ tsmd/Sources/tsmd/Daemon.swift | 72 +++++-- tsmd/Sources/tsmd/IdleTracker.swift | 22 +++ tsmd/Sources/tsmd/JSONRPCHandler.swift | 5 +- .../Tests/tsmdTests/DaemonIdleQuitTests.swift | 118 ++++++++++++ tsmd/Tests/tsmdTests/IdleTrackerTests.swift | 70 +++++++ .../Tests/tsmdTests/JSONRPCHandlerTests.swift | 24 ++- 10 files changed, 325 insertions(+), 333 deletions(-) delete mode 100644 npm/wrapper/scripts/shim.test.js create mode 100644 tsmd/Sources/tsmd/IdleTracker.swift create mode 100644 tsmd/Tests/tsmdTests/DaemonIdleQuitTests.swift create mode 100644 tsmd/Tests/tsmdTests/IdleTrackerTests.swift diff --git a/RELEASING.md b/RELEASING.md index 2d9def8..d5abd1e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -71,7 +71,7 @@ The workflow: 6. Publishes `@tashian/tsm@` second 7. Both npm publishes carry provenance attestations linking back to this exact workflow run -On every invocation, the `@tashian/tsm` wrapper (`shim.js`) SIGTERMs any `tsmd` running from a different install location than the one this `tsm` is about to use, so upgrades pick up the new daemon on the next command. Doing it in the shim instead of a `postinstall` script means it works under bun, which silently skips lifecycle scripts by default. No-op on non-darwin. +After an upgrade, the running `tsmd` keeps serving the old code until it idle-quits (all sessions past TTL + 30 minutes without an RPC), at which point the next `tsm` command spawns a fresh daemon from the new binary. Users who want the upgrade applied immediately can `tsm daemon stop`. ## Local dry-run diff --git a/npm/wrapper/README.md b/npm/wrapper/README.md index 891490f..2b0e9f6 100644 --- a/npm/wrapper/README.md +++ b/npm/wrapper/README.md @@ -17,7 +17,7 @@ pnpm add -g @tashian/tsm This package is a thin shim. The actual binaries are pulled in via `optionalDependencies` based on your platform — currently only `@tashian/tsm-darwin-arm64` (Apple Silicon Macs). -On upgrade, the next `tsm` invocation SIGTERMs any `tsmd` running from a different install location than the one this `tsm` is about to use, then the daemon respawns on demand — so you'll Touch ID re-unlock once. This runs in the wrapper itself rather than a `postinstall` script so it works under bun (which skips lifecycle scripts by default) and any other manager that does the same. +After an upgrade, the running `tsmd` keeps serving until your sessions all hit their TTL and the daemon has been idle for 30 minutes — at which point it exits and the next `tsm` command spawns a fresh daemon from the new binary. If you want the new daemon immediately, run `tsm daemon stop`. ## Usage diff --git a/npm/wrapper/scripts/shim.test.js b/npm/wrapper/scripts/shim.test.js deleted file mode 100644 index a325757..0000000 --- a/npm/wrapper/scripts/shim.test.js +++ /dev/null @@ -1,180 +0,0 @@ -"use strict"; - -// Tests for the helper functions exported by shim.js. The shim itself runs -// every time someone invokes `tsm`, so it has to fail-open on every error -// path; these tests pin that behavior down. -// -// node --test scripts/shim.test.js - -const test = require("node:test"); -const assert = require("node:assert/strict"); - -const { - pgrepUserTsmds, - getPidArgv0, - findStaleTsmdPids, - killStalePids, -} = require("../shim.js"); - -const USER = process.env.USER || "nobody"; - -// Build a fake execFileSync that dispatches on `${cmd} ${args.join(" ")}`. -function fakeExecFor(responses) { - return (cmd, args) => { - const key = `${cmd} ${args.join(" ")}`; - const r = responses[key]; - if (!r) { - throw new Error(`unexpected exec: ${key}`); - } - if (r.error) { - const e = new Error(r.error.message || "exec failed"); - if (r.error.code) e.code = r.error.code; - throw e; - } - return Buffer.from(r.stdout || ""); - }; -} - -// --- pgrepUserTsmds --------------------------------------------------------- - -test("pgrepUserTsmds returns [] when pgrep finds no match", () => { - const exec = fakeExecFor({ - [`pgrep -u ${USER} -f ^.*tsmd( |$)`]: { error: { message: "no match" } }, - }); - assert.deepEqual(pgrepUserTsmds({ execFileSync: exec }), []); -}); - -test("pgrepUserTsmds returns [] when pgrep is missing (ENOENT)", () => { - const exec = (cmd) => { - const e = new Error(`spawn ${cmd} ENOENT`); - e.code = "ENOENT"; - throw e; - }; - assert.deepEqual(pgrepUserTsmds({ execFileSync: exec }), []); -}); - -test("pgrepUserTsmds parses pids out of pgrep stdout", () => { - const exec = fakeExecFor({ - [`pgrep -u ${USER} -f ^.*tsmd( |$)`]: { stdout: "111\n222\n" }, - }); - const got = pgrepUserTsmds({ execFileSync: exec }).sort((a, b) => a - b); - assert.deepEqual(got, [111, 222]); -}); - -// --- getPidArgv0 ------------------------------------------------------------ - -test("getPidArgv0 returns first whitespace-delimited token of `ps args`", () => { - const exec = fakeExecFor({ - "ps -p 111 -o args=": { - stdout: "/Users/x/.local/bin/tsmd --socket /tmp/s\n", - }, - }); - assert.equal( - getPidArgv0(111, { execFileSync: exec }), - "/Users/x/.local/bin/tsmd", - ); -}); - -test("getPidArgv0 returns null when ps errors (process gone race)", () => { - const exec = fakeExecFor({ - "ps -p 111 -o args=": { error: { message: "no such proc" } }, - }); - assert.equal(getPidArgv0(111, { execFileSync: exec }), null); -}); - -test("getPidArgv0 returns null on empty stdout", () => { - const exec = fakeExecFor({ - "ps -p 111 -o args=": { stdout: "" }, - }); - assert.equal(getPidArgv0(111, { execFileSync: exec }), null); -}); - -// --- findStaleTsmdPids ------------------------------------------------------ - -test("findStaleTsmdPids returns pids whose argv0 differs from expected path", () => { - const expected = "/new/prefix/bin/tsmd"; - const exec = fakeExecFor({ - [`pgrep -u ${USER} -f ^.*tsmd( |$)`]: { stdout: "111\n222\n333\n" }, - "ps -p 111 -o args=": { - stdout: "/Users/x/.local/bin/tsmd --socket /tmp/a\n", - }, - "ps -p 222 -o args=": { - stdout: "/new/prefix/bin/tsmd --socket /tmp/b\n", - }, - "ps -p 333 -o args=": { - stdout: "/opt/homebrew/bin/tsmd --socket /tmp/c\n", - }, - }); - const stale = findStaleTsmdPids(expected, { execFileSync: exec }); - assert.deepEqual( - stale.sort((a, b) => a - b), - [111, 333], - ); -}); - -test("findStaleTsmdPids returns [] when only the expected daemon is running", () => { - const expected = "/new/prefix/bin/tsmd"; - const exec = fakeExecFor({ - [`pgrep -u ${USER} -f ^.*tsmd( |$)`]: { stdout: "222\n" }, - "ps -p 222 -o args=": { - stdout: "/new/prefix/bin/tsmd --socket /tmp/b\n", - }, - }); - assert.deepEqual(findStaleTsmdPids(expected, { execFileSync: exec }), []); -}); - -test("findStaleTsmdPids returns [] when no tsmd is running at all", () => { - const exec = fakeExecFor({ - [`pgrep -u ${USER} -f ^.*tsmd( |$)`]: { error: { message: "none" } }, - }); - assert.deepEqual( - findStaleTsmdPids("/new/prefix/bin/tsmd", { execFileSync: exec }), - [], - ); -}); - -test("findStaleTsmdPids skips pids whose ps lookup races (process gone)", () => { - const exec = fakeExecFor({ - [`pgrep -u ${USER} -f ^.*tsmd( |$)`]: { stdout: "111\n222\n" }, - "ps -p 111 -o args=": { error: { message: "gone" } }, - "ps -p 222 -o args=": { stdout: "/old/tsmd --socket s\n" }, - }); - assert.deepEqual( - findStaleTsmdPids("/new/prefix/bin/tsmd", { execFileSync: exec }), - [222], - ); -}); - -// --- killStalePids ---------------------------------------------------------- - -test("killStalePids sends SIGTERM to each pid", () => { - const sent = []; - killStalePids([1, 2, 3], { kill: (pid, sig) => sent.push([pid, sig]) }); - assert.deepEqual(sent, [ - [1, "SIGTERM"], - [2, "SIGTERM"], - [3, "SIGTERM"], - ]); -}); - -test("killStalePids swallows ESRCH and EPERM, does not throw", () => { - const tries = []; - const kill = (pid) => { - tries.push(pid); - const e = new Error("x"); - e.code = pid === 1 ? "ESRCH" : "EPERM"; - throw e; - }; - killStalePids([1, 2], { kill }); - assert.deepEqual(tries, [1, 2]); -}); - -test("killStalePids is a no-op for empty list", () => { - let called = false; - killStalePids([], { - kill: () => { - called = true; - }, - }); - assert.equal(called, false); -}); diff --git a/npm/wrapper/shim.js b/npm/wrapper/shim.js index fd95737..039e824 100644 --- a/npm/wrapper/shim.js +++ b/npm/wrapper/shim.js @@ -1,148 +1,51 @@ "use strict"; const path = require("node:path"); -const { spawnSync, execFileSync } = require("node:child_process"); +const { spawnSync } = require("node:child_process"); const platforms = { "darwin-arm64": "@tashian/tsm-darwin-arm64", }; -// pgrep -u $USER -f '^.*tsmd( |$)' — every tsmd process owned by the current -// user. Empty list on no-match, ENOENT, or any other failure: the shim must -// never block tsm. -function pgrepUserTsmds({ execFileSync: exec = execFileSync } = {}) { - const user = process.env.USER || "nobody"; - let out; - try { - out = exec("pgrep", ["-u", user, "-f", "^.*tsmd( |$)"], { - stdio: ["ignore", "pipe", "ignore"], - }); - } catch { - return []; - } - return String(out) - .split("\n") - .map((s) => s.trim()) - .filter(Boolean) - .map((s) => parseInt(s, 10)) - .filter((n) => Number.isFinite(n) && n > 0); -} - -// First whitespace-delimited token of `ps -p PID -o args=` — argv[0], i.e. -// the path the kernel exec'd. Returns null if ps fails or produces no -// output (e.g. the process exited between pgrep and ps). -function getPidArgv0(pid, { execFileSync: exec = execFileSync } = {}) { - let out; - try { - out = exec("ps", ["-p", String(pid), "-o", "args="], { - stdio: ["ignore", "pipe", "ignore"], - }); - } catch { - return null; - } - const line = String(out).trim(); - if (!line) { - return null; - } - return line.split(/\s+/)[0]; -} +const key = `${process.platform}-${process.arch}`; +const pkg = platforms[key]; -// Tsmd pids whose argv[0] is anything other than expectedPath. "Stale" = -// launched from a different prefix — e.g. ~/.local/bin/tsmd left over from -// a manual install before this user upgraded via npm/bun. -function findStaleTsmdPids(expectedPath, deps = {}) { - const pids = pgrepUserTsmds(deps); - const stale = []; - for (const pid of pids) { - const argv0 = getPidArgv0(pid, deps); - if (argv0 && argv0 !== expectedPath) { - stale.push(pid); - } - } - return stale; +if (!pkg) { + console.error( + `tsm: unsupported platform ${key}. ` + + `tsm currently ships binaries for: ${Object.keys(platforms).join(", ")}.`, + ); + process.exit(1); } -// SIGTERM each pid. Swallow ESRCH (race) and EPERM (cross-user daemon — -// possible after a stray `sudo npm install`); silently swallow anything -// else. The daemon's own SIGTERM handler closes the socket and zeros the -// master key on exit; the next `tsm` command will EnsureRunning a fresh -// daemon at TSM_TSMD_BIN. -function killStalePids(pids, { kill = process.kill.bind(process) } = {}) { - for (const pid of pids) { - try { - kill(pid, "SIGTERM"); - } catch { - // best effort - } - } +let pkgRoot; +try { + pkgRoot = path.dirname(require.resolve(`${pkg}/package.json`)); +} catch { + console.error( + `tsm: prebuilt binary package "${pkg}" was not installed.\n` + + `npm or bun likely skipped it because of an OS/arch mismatch, or you used --no-optional.\n` + + `Reinstall without --no-optional, or build from source: https://github.com/tashian/tsm`, + ); + process.exit(1); } -function main(argv = process.argv) { - const key = `${process.platform}-${process.arch}`; - const pkg = platforms[key]; - - if (!pkg) { - console.error( - `tsm: unsupported platform ${key}. ` + - `tsm currently ships binaries for: ${Object.keys(platforms).join(", ")}.`, - ); - return 1; - } - - let pkgRoot; - try { - pkgRoot = path.dirname(require.resolve(`${pkg}/package.json`)); - } catch { - console.error( - `tsm: prebuilt binary package "${pkg}" was not installed.\n` + - `npm or bun likely skipped it because of an OS/arch mismatch, or you used --no-optional.\n` + - `Reinstall without --no-optional, or build from source: https://github.com/tashian/tsm`, - ); - return 1; - } - - const binDir = path.join(pkgRoot, "bin"); - const tsm = path.join(binDir, "tsm"); - const tsmd = path.join(binDir, "tsmd"); +const binDir = path.join(pkgRoot, "bin"); +const tsm = path.join(binDir, "tsm"); +const tsmd = path.join(binDir, "tsmd"); - // tsm finds tsmd next to its own executable (internal/paths/paths.go), but - // global installs symlink the wrapper, and os.Executable() on Darwin can - // return the symlink path. Pinning TSM_TSMD_BIN sidesteps that entirely. - if (!process.env.TSM_TSMD_BIN) { - process.env.TSM_TSMD_BIN = tsmd; - } - - // SIGTERM any tsmd whose argv[0] doesn't match TSM_TSMD_BIN — stale from - // a previous install at a different prefix. Bun skips lifecycle scripts - // by default and there's no consumer package.json on a global install - // for `trustedDependencies` to apply, so the shim is the reliable place - // to do this. Cheap (one pgrep + one ps per stale process), no-op when - // the running daemon already matches. - try { - const stale = findStaleTsmdPids(process.env.TSM_TSMD_BIN); - killStalePids(stale); - } catch { - // never block tsm - } - - const result = spawnSync(tsm, argv.slice(2), { stdio: "inherit" }); - - if (result.error) { - console.error(`tsm: failed to exec ${tsm}: ${result.error.message}`); - return 1; - } - - return result.status ?? 1; +// tsm finds tsmd next to its own executable (internal/paths/paths.go), but +// global installs symlink the wrapper, and os.Executable() on Darwin can +// return the symlink path. Pinning TSM_TSMD_BIN sidesteps that entirely. +if (!process.env.TSM_TSMD_BIN) { + process.env.TSM_TSMD_BIN = tsmd; } -module.exports = { - pgrepUserTsmds, - getPidArgv0, - findStaleTsmdPids, - killStalePids, - main, -}; +const result = spawnSync(tsm, process.argv.slice(2), { stdio: "inherit" }); -if (require.main === module) { - process.exit(main()); +if (result.error) { + console.error(`tsm: failed to exec ${tsm}: ${result.error.message}`); + process.exit(1); } + +process.exit(result.status ?? 1); diff --git a/tsmd/Sources/tsmd/Daemon.swift b/tsmd/Sources/tsmd/Daemon.swift index 7909466..fa62dbc 100644 --- a/tsmd/Sources/tsmd/Daemon.swift +++ b/tsmd/Sources/tsmd/Daemon.swift @@ -3,33 +3,59 @@ import Foundation final class Daemon: @unchecked Sendable { let socketPath: String let vault: Vault + let idleTracker: IdleTracker let server: SocketServer + let idleQuitSeconds: TimeInterval + let tickInterval: TimeInterval private var ttlTimer: DispatchSourceTimer? private var systemEvents: SystemEvents? private var shutdownObserver: NSObjectProtocol? private let shutdownSemaphore = DispatchSemaphore(value: 0) - init(socketPath: String? = nil) { + init( + socketPath: String? = nil, + idleQuitSeconds: TimeInterval = 1800, + tickInterval: TimeInterval = 15, + vault: Vault? = nil, + idleTracker: IdleTracker? = nil + ) { let path = socketPath ?? Paths.socketPath - let crypto = AESGCMCrypto() - let keychain = MacKeychain() - let auth = TouchIDAuth() - let store = FileVaultStore() - let accessLog = FileAccessLog() + let resolvedVault = vault ?? { + let crypto = AESGCMCrypto() + let keychain = MacKeychain() + let auth = TouchIDAuth() + let store = FileVaultStore() + let accessLog = FileAccessLog() + return Vault( + crypto: crypto, + keychain: keychain, + auth: auth, + store: store, + accessLog: accessLog + ) + }() + let resolvedTracker = idleTracker ?? IdleTracker() - self.vault = Vault( - crypto: crypto, - keychain: keychain, - auth: auth, - store: store, - accessLog: accessLog - ) - let handler = JSONRPCHandler(vault: vault) + self.vault = resolvedVault + self.idleTracker = resolvedTracker + self.idleQuitSeconds = idleQuitSeconds + self.tickInterval = tickInterval + + let handler = JSONRPCHandler(vault: resolvedVault, idleTracker: resolvedTracker) self.server = SocketServer(socketPath: path, handler: handler) self.socketPath = path } + /// True when the vault has no decrypted state AND no client has talked + /// to the daemon for at least `idleQuitSeconds`. The periodic timer in + /// `run()` calls this and posts `.tsmdShutdown` when it returns true. + func shouldIdleQuit(now: Date = Date()) async -> Bool { + let locked = await vault.isLocked + let idle = await idleTracker.isIdle(idleSeconds: idleQuitSeconds, now: now) + return locked && idle + } + func run() throws { try server.start() @@ -37,13 +63,20 @@ final class Daemon: @unchecked Sendable { print(socketPath) fflush(stdout) - // TTL check every 15 seconds — short enough that even a 60 s TTL - // expires close to its target while keeping idle CPU near zero. + // Periodic tick: expire stale sessions, then decide whether the daemon + // can quit because nobody is using it. `tickInterval` defaults to 15 s + // — short enough that a 60 s TTL expires close to its target while + // keeping idle CPU near zero, and tunable down for tests. let timer = DispatchSource.makeTimerSource(queue: .global()) - timer.schedule(deadline: .now() + 15, repeating: 15) + timer.schedule(deadline: .now() + tickInterval, repeating: tickInterval) timer.setEventHandler { [weak self] in guard let self = self else { return } - Task { await self.vault.checkTTL() } + Task { + await self.vault.checkTTL() + if await self.shouldIdleQuit() { + NotificationCenter.default.post(name: .tsmdShutdown, object: nil) + } + } } timer.resume() ttlTimer = timer @@ -55,7 +88,8 @@ final class Daemon: @unchecked Sendable { events.start() systemEvents = events - // Listen for shutdown notification (from daemon.shutdown RPC) + // Listen for shutdown notification (from daemon.shutdown RPC or the + // idle-quit tick). shutdownObserver = NotificationCenter.default.addObserver( forName: .tsmdShutdown, object: nil, queue: nil ) { [weak self] _ in diff --git a/tsmd/Sources/tsmd/IdleTracker.swift b/tsmd/Sources/tsmd/IdleTracker.swift new file mode 100644 index 0000000..8176c34 --- /dev/null +++ b/tsmd/Sources/tsmd/IdleTracker.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Tracks the timestamp of the most recent client activity. Used by the +/// daemon to decide when it can shut itself down: combined with the vault's +/// locked state, an idle window past the threshold means no client cares +/// about this process anymore, so a fresh `tsm` invocation may as well +/// respawn from the latest binary on disk. +actor IdleTracker { + private(set) var lastActivityAt: Date + + init(now: Date = Date()) { + self.lastActivityAt = now + } + + func bump(now: Date = Date()) { + lastActivityAt = now + } + + func isIdle(idleSeconds: TimeInterval, now: Date = Date()) -> Bool { + return now.timeIntervalSince(lastActivityAt) >= idleSeconds + } +} diff --git a/tsmd/Sources/tsmd/JSONRPCHandler.swift b/tsmd/Sources/tsmd/JSONRPCHandler.swift index 0f05a3e..86525fe 100644 --- a/tsmd/Sources/tsmd/JSONRPCHandler.swift +++ b/tsmd/Sources/tsmd/JSONRPCHandler.swift @@ -2,12 +2,15 @@ import Foundation actor JSONRPCHandler { let vault: Vault + let idleTracker: IdleTracker - init(vault: Vault) { + init(vault: Vault, idleTracker: IdleTracker = IdleTracker()) { self.vault = vault + self.idleTracker = idleTracker } func handle(_ request: JSONRPCRequest, sessionID: pid_t) async -> JSONRPCResponse { + await idleTracker.bump() do { let result = try await dispatch(request, sessionID: sessionID) return JSONRPCResponse(result: result, id: request.id) diff --git a/tsmd/Tests/tsmdTests/DaemonIdleQuitTests.swift b/tsmd/Tests/tsmdTests/DaemonIdleQuitTests.swift new file mode 100644 index 0000000..c438209 --- /dev/null +++ b/tsmd/Tests/tsmdTests/DaemonIdleQuitTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import tsmd + +final class DaemonIdleQuitTests: XCTestCase { + private func makeVault() -> Vault { + Vault( + crypto: MockCrypto(), + keychain: MockKeychain(), + auth: MockAuth(), + store: MockVaultStore(), + accessLog: MockAccessLog() + ) + } + + private func tmpSocketPath() -> String { + NSTemporaryDirectory() + "tsmd-idle-\(UUID().uuidString).sock" + } + + // MARK: - Predicate + + func testShouldIdleQuitWhenLockedAndIdle() async { + let vault = makeVault() + let tracker = IdleTracker(now: .distantPast) + let daemon = Daemon( + socketPath: tmpSocketPath(), + idleQuitSeconds: 60, + vault: vault, + idleTracker: tracker + ) + let shouldQuit = await daemon.shouldIdleQuit() + XCTAssertTrue(shouldQuit, "locked vault + last activity in distant past must request quit") + } + + func testShouldNotIdleQuitWhenLockedButRecent() async { + let vault = makeVault() + let tracker = IdleTracker(now: Date()) + let daemon = Daemon( + socketPath: tmpSocketPath(), + idleQuitSeconds: 60, + vault: vault, + idleTracker: tracker + ) + let shouldQuit = await daemon.shouldIdleQuit() + XCTAssertFalse(shouldQuit, "locked but within idle window must not request quit") + } + + func testShouldNotIdleQuitWhenUnlockedEvenIfIdle() async throws { + let vault = makeVault() + try await vault.initialize(recoveryPassphrase: nil, sessionID: 1234) + // Vault is now unlocked (data != nil). + let tracker = IdleTracker(now: .distantPast) + let daemon = Daemon( + socketPath: tmpSocketPath(), + idleQuitSeconds: 60, + vault: vault, + idleTracker: tracker + ) + let shouldQuit = await daemon.shouldIdleQuit() + XCTAssertFalse(shouldQuit, "unlocked vault must not request quit even when no recent RPCs") + } + + // MARK: - Wiring (timer → notification → shutdown) + + func testRunReturnsWhenIdleQuitFires() async throws { + let socketPath = tmpSocketPath() + let vault = makeVault() + // Starts locked (data == nil) and tracker is in distant past, so the + // very first tick should request shutdown. + let tracker = IdleTracker(now: .distantPast) + let daemon = Daemon( + socketPath: socketPath, + idleQuitSeconds: 0.0, + tickInterval: 0.05, + vault: vault, + idleTracker: tracker + ) + + let runDone = expectation(description: "Daemon.run returns after idle-quit fires") + Task.detached { + try? daemon.run() + runDone.fulfill() + } + await fulfillment(of: [runDone], timeout: 5.0) + + // Socket file is unlinked by SocketServer.stop(). + XCTAssertFalse( + FileManager.default.fileExists(atPath: socketPath), + "shutdown should unlink the socket file" + ) + } + + func testRunDoesNotIdleQuitWhileVaultIsUnlocked() async throws { + let socketPath = tmpSocketPath() + let vault = makeVault() + try await vault.initialize(recoveryPassphrase: nil, sessionID: 5555) + // Tracker is distant past — only the unlocked-vault check should + // keep the daemon alive. + let tracker = IdleTracker(now: .distantPast) + let daemon = Daemon( + socketPath: socketPath, + idleQuitSeconds: 0.0, + tickInterval: 0.02, + vault: vault, + idleTracker: tracker + ) + + let runDone = expectation(description: "Daemon.run should NOT return") + runDone.isInverted = true + Task.detached { + try? daemon.run() + runDone.fulfill() + } + await fulfillment(of: [runDone], timeout: 0.3) + + // Tear down so we don't leak a running daemon into other tests. + daemon.shutdown() + } +} diff --git a/tsmd/Tests/tsmdTests/IdleTrackerTests.swift b/tsmd/Tests/tsmdTests/IdleTrackerTests.swift new file mode 100644 index 0000000..82c0b26 --- /dev/null +++ b/tsmd/Tests/tsmdTests/IdleTrackerTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import tsmd + +final class IdleTrackerTests: XCTestCase { + func testInitialActivityTimeIsTheGivenNow() async { + let t0 = Date(timeIntervalSince1970: 1000) + let tracker = IdleTracker(now: t0) + let last = await tracker.lastActivityAt + XCTAssertEqual(last, t0) + } + + func testBumpUpdatesActivityTime() async { + let t0 = Date(timeIntervalSince1970: 1000) + let tracker = IdleTracker(now: t0) + let t1 = Date(timeIntervalSince1970: 2000) + await tracker.bump(now: t1) + let last = await tracker.lastActivityAt + XCTAssertEqual(last, t1) + } + + func testIsIdleFalseBeforeThreshold() async { + let t0 = Date(timeIntervalSince1970: 1000) + let tracker = IdleTracker(now: t0) + let isIdle = await tracker.isIdle( + idleSeconds: 60, + now: Date(timeIntervalSince1970: 1030) + ) + XCTAssertFalse(isIdle) + } + + func testIsIdleTrueAfterThreshold() async { + let t0 = Date(timeIntervalSince1970: 1000) + let tracker = IdleTracker(now: t0) + let isIdle = await tracker.isIdle( + idleSeconds: 60, + now: Date(timeIntervalSince1970: 1061) + ) + XCTAssertTrue(isIdle) + } + + func testIsIdleTrueAtExactThreshold() async { + let t0 = Date(timeIntervalSince1970: 1000) + let tracker = IdleTracker(now: t0) + // Exact boundary uses >= so we don't dance around drift in the + // 15s polling timer that wraps this check in production. + let isIdle = await tracker.isIdle( + idleSeconds: 60, + now: Date(timeIntervalSince1970: 1060) + ) + XCTAssertTrue(isIdle) + } + + func testBumpResetsIdleness() async { + let t0 = Date(timeIntervalSince1970: 1000) + let tracker = IdleTracker(now: t0) + await tracker.bump(now: Date(timeIntervalSince1970: 2000)) + + let isIdleEarly = await tracker.isIdle( + idleSeconds: 60, + now: Date(timeIntervalSince1970: 2050) + ) + XCTAssertFalse(isIdleEarly, "bump should reset the idle window") + + let isIdleLate = await tracker.isIdle( + idleSeconds: 60, + now: Date(timeIntervalSince1970: 2070) + ) + XCTAssertTrue(isIdleLate) + } +} diff --git a/tsmd/Tests/tsmdTests/JSONRPCHandlerTests.swift b/tsmd/Tests/tsmdTests/JSONRPCHandlerTests.swift index 82882b5..e50b5c5 100644 --- a/tsmd/Tests/tsmdTests/JSONRPCHandlerTests.swift +++ b/tsmd/Tests/tsmdTests/JSONRPCHandlerTests.swift @@ -5,6 +5,7 @@ final class JSONRPCHandlerTests: XCTestCase { var handler: JSONRPCHandler! var vault: Vault! var auth: MockAuth! + var idleTracker: IdleTracker! let sid: pid_t = 5000 @@ -17,7 +18,28 @@ final class JSONRPCHandlerTests: XCTestCase { store: MockVaultStore(), accessLog: MockAccessLog() ) - handler = JSONRPCHandler(vault: vault) + idleTracker = IdleTracker(now: Date(timeIntervalSince1970: 1000)) + handler = JSONRPCHandler(vault: vault, idleTracker: idleTracker) + } + + // MARK: - Idle bump + + func testHandleBumpsIdleTracker() async { + let beforeBump = await idleTracker.lastActivityAt + XCTAssertEqual(beforeBump, Date(timeIntervalSince1970: 1000)) + + _ = await handler.handle(makeRequest(method: "vault.status"), sessionID: sid) + + let afterBump = await idleTracker.lastActivityAt + XCTAssertGreaterThan(afterBump, beforeBump, + "every RPC must bump lastActivityAt so the daemon's idle-quit timer doesn't fire under load") + } + + func testHandleBumpsIdleTrackerEvenForUnknownMethods() async { + _ = await handler.handle(makeRequest(method: "no.such.method"), sessionID: sid) + let afterBump = await idleTracker.lastActivityAt + XCTAssertGreaterThan(afterBump, Date(timeIntervalSince1970: 1000), + "an unknown-method error response is still client activity") } private func makeRequest(method: String, params: [String: JSONValue]? = nil, id: Int = 1) -> JSONRPCRequest {