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 {