diff --git a/bin/status.ts b/bin/status.ts index 5c1d15c16b..2b1eb849fe 100644 --- a/bin/status.ts +++ b/bin/status.ts @@ -9,6 +9,7 @@ import hdbLog from '../utility/logging/harper_logger.ts'; import * as systemInformation from '../utility/environment/systemInformation.ts'; import * as envMgr from '../utility/environment/environmentManager.ts'; import * as installation from '../utility/installation.ts'; +import { prettyDuration } from '../utility/common_utils.ts'; envMgr.initSync(); const STATUSES = { @@ -22,6 +23,23 @@ let hdbRoot; export default status; +/** Uptime in ms between two epoch-ms timestamps. Both are absolute (UTC) instants, so the result is + * timezone- and DST-independent; negative spans clamp to 0. */ +export function processUptimeMs(startMs: number, nowMs: number): number { + return Math.max(0, Math.round(nowMs - startMs)); +} + +/** Format uptime, then print the status object as YAML. */ +function report(status: any): void { + if (typeof status.harperdb.uptime === 'number') { + status.harperdb.uptime = prettyDuration(status.harperdb.uptime); + } + console.log(YAML.stringify(status)); + // Set exitCode rather than calling process.exit(0), which can truncate buffered stdout when the + // output is piped; the event loop drains and exits 0 on its own. + process.exitCode = 0; +} + async function status() { let status: any = { harperdb: { @@ -31,20 +49,19 @@ async function status() { if (!installation.isHdbInstalled(envMgr, hdbLog)) { status.harperdb.status = STATUSES.NOT_INSTALLED; - console.log(YAML.stringify(status)); - return; + return report(status); } hdbRoot = envMgr.get(hdbTerms.CONFIG_PARAMS.ROOTPATH); + const pidFile = path.join(hdbRoot, hdbTerms.HDB_PID_FILE); let hdbPid; try { - hdbPid = Number.parseInt(await fs.readFile(path.join(hdbRoot, hdbTerms.HDB_PID_FILE), 'utf8')); + hdbPid = Number.parseInt(await fs.readFile(pidFile, 'utf8')); } catch (err) { if (err.code === hdbTerms.NODE_ERROR_CODES.ENOENT) { - hdbLog.info('`harperdb status` did not find a hdb.pid file'); + // A missing pid file is the normal stopped state (clean shutdown removes it), not an error. status.harperdb.status = STATUSES.STOPPED; - console.log(YAML.stringify(status)); - return; + return report(status); } throw err; @@ -56,10 +73,19 @@ async function status() { if (proc.pid === hdbPid) { status.harperdb.status = STATUSES.RUNNING; status.harperdb.pid = hdbPid; + // `status` is a separate short-lived CLI process, so `process.uptime()` would report its own + // age, not the server's. The pid file is written once at startup, so its mtime is an epoch + // timestamp for when the server started — a numeric UTC instant, so subtracting it from now + // is DST-safe (no local-time string to round-trip, unlike systeminformation's `proc.started`). + try { + const { mtimeMs } = await fs.stat(pidFile); + status.harperdb.uptime = processUptimeMs(mtimeMs, Date.now()); + } catch (err) { + hdbLog.warn(`\`harperdb status\` could not determine uptime: ${err}`); + } break; } } - console.log(YAML.stringify(status)); - process.exit(0); + return report(status); } diff --git a/unitTests/bin/status.test.js b/unitTests/bin/status.test.js index ffb6e2471f..ff4a7fcc78 100644 --- a/unitTests/bin/status.test.js +++ b/unitTests/bin/status.test.js @@ -1,35 +1,46 @@ 'use strict'; -const chai = require('chai'); +const assert = require('assert'); const sinon = require('sinon'); -const { expect } = chai; const fs = require('fs-extra'); const env_mgr = require('#src/utility/environment/environmentManager'); const sys_info = require('#src/utility/environment/systemInformation'); const hdb_terms = require('#src/utility/hdbTerms'); const installation = require('#src/utility/installation'); -const status = require('#src/bin/status').default; +const status_module = require('#src/bin/status'); +const status = status_module.default; +const { processUptimeMs } = status_module; + +describe('processUptimeMs', () => { + it('derives uptime in ms between two epoch timestamps', () => { + assert.strictEqual(processUptimeMs(1_000_000, 1_000_000 + 97_702_000), 97_702_000); + }); + + it('rounds to the nearest ms', () => { + assert.strictEqual(processUptimeMs(0, 1500.6), 1501); + }); + + it('clamps a future start time to 0', () => { + assert.strictEqual(processUptimeMs(5000, 0), 0); + }); +}); describe('Test status module', () => { const sandbox = sinon.createSandbox(); + const STARTED_MS = 1_700_000_000_000; // pid-file mtime (epoch ms) let console_log_stub; let get_hdb_process_info_stub; + let fs_stat_stub; const fake_hdb_process_info = { - core: [ - { - pid: 62076, - }, - { - pid: 55297, - }, - ], + core: [{ pid: 62076 }, { pid: 55297 }], }; before(() => { console_log_stub = sandbox.stub(console, 'log'); env_mgr.setProperty(hdb_terms.CONFIG_PARAMS.ROOTPATH, 'unit-test'); sandbox.stub(fs, 'readFile').resolves('62076'); + fs_stat_stub = sandbox.stub(fs, 'stat').resolves({ mtimeMs: STARTED_MS }); get_hdb_process_info_stub = sandbox.stub(sys_info, 'getHDBProcessInfo').resolves(fake_hdb_process_info); sandbox.stub(installation, 'isHdbInstalled').returns(true); }); @@ -40,21 +51,30 @@ describe('Test status module', () => { afterEach(() => { sandbox.resetHistory(); + get_hdb_process_info_stub.resolves(fake_hdb_process_info); + fs_stat_stub.resolves({ mtimeMs: STARTED_MS }); }); - it('Test status is returned as expected', async () => { - const process_exit_stub = sandbox.stub(process, 'exit'); + it('reports running, pid, and a formatted uptime', async () => { + await status(); + const output = console_log_stub.args[0][0]; + assert.match(output, /status: running/); + assert.match(output, /pid: 62076/); + // Uptime is present and non-empty; the exact derivation is covered by the processUptimeMs tests. + assert.match(output, /uptime: \S/); + }); + + it('omits uptime when the pid file cannot be stat-ed but still reports running + pid', async () => { + fs_stat_stub.rejects(new Error('stat failed')); + await status(); - process_exit_stub.restore(); - expect(console_log_stub.args[0][0]).to.eql('harperdb:\n status: running\n pid: 62076\n'); + assert.strictEqual(console_log_stub.args[0][0], 'harperdb:\n status: running\n pid: 62076\n'); }); - it('Test status when nothing is running', async () => { - const process_exit_stub = sandbox.stub(process, 'exit'); + it('reports stopped when nothing is running', async () => { get_hdb_process_info_stub.resolves({ core: [] }); await status(); - process_exit_stub.restore(); - expect(console_log_stub.args[0][0]).to.eql('harperdb:\n status: stopped\n'); + assert.strictEqual(console_log_stub.args[0][0], 'harperdb:\n status: stopped\n'); }); }); diff --git a/unitTests/utility/common_utils.test.js b/unitTests/utility/common_utils.test.js index 1f3f25a978..d4f56563b0 100644 --- a/unitTests/utility/common_utils.test.js +++ b/unitTests/utility/common_utils.test.js @@ -586,6 +586,28 @@ describe('Test common_utils module', () => { }); }); + describe('Test prettyDuration', () => { + it('drops only leading zero units', () => { + assert.strictEqual(cu.prettyDuration(5000), '5s'); + assert.strictEqual(cu.prettyDuration(90000), '1m 30s'); + assert.strictEqual(cu.prettyDuration(97702000), '1d 3h 8m 22s'); + }); + it('keeps interior zero units once a larger unit is present', () => { + assert.strictEqual(cu.prettyDuration(86405000), '1d 0h 0m 5s'); + }); + it('floors sub-second and non-positive values to 0s', () => { + assert.strictEqual(cu.prettyDuration(0), '0s'); + assert.strictEqual(cu.prettyDuration(999), '0s'); + assert.strictEqual(cu.prettyDuration(-5000), '0s'); + }); + it('floors non-finite values to 0s', () => { + assert.strictEqual(cu.prettyDuration(NaN), '0s'); + assert.strictEqual(cu.prettyDuration(Infinity), '0s'); + assert.strictEqual(cu.prettyDuration(-Infinity), '0s'); + assert.strictEqual(cu.prettyDuration(cu.convertToMS('abc')), '0s'); + }); + }); + describe('Test httpRequest timeout', () => { const http = require('node:http'); let server, port; diff --git a/unitTests/utility/environment/systemInformation.test.js b/unitTests/utility/environment/systemInformation.test.js index 5a65e291f2..0b2c92fe83 100644 --- a/unitTests/utility/environment/systemInformation.test.js +++ b/unitTests/utility/environment/systemInformation.test.js @@ -130,7 +130,7 @@ const EXPECTED_PROPERTIES = { 'node_version', 'npm_version', ], - time: ['current', 'uptime', 'timezone', 'timezoneName'], + time: ['current', 'uptime', 'timezone', 'timezoneName', 'process_uptime'], cpu: [ 'manufacturer', 'brand', @@ -253,6 +253,12 @@ describe('test systemInformation module', () => { it('test getTimeInfo function', () => { const results = system_information.getTimeInfo(); assert.deepEqual(Object.keys(results).sort(), EXPECTED_PROPERTIES.time.sort()); + // process_uptime is process.uptime() in seconds (matching the sibling `uptime`), not ms. + assert.equal(typeof results.process_uptime, 'number'); + assert.ok( + Math.abs(results.process_uptime - process.uptime()) <= 2, + 'process_uptime should be process uptime in seconds' + ); }); it('test getCPUInfo function', async () => { diff --git a/utility/common_utils.ts b/utility/common_utils.ts index 56880594c6..b9140e9c9a 100644 --- a/utility/common_utils.ts +++ b/utility/common_utils.ts @@ -869,4 +869,34 @@ export function convertToMS(interval: any) { } return seconds * 1000; } + +/** + * Render a millisecond duration as a compact human-readable string, e.g. `1d 3h 8m 22s`. Drops only + * the leading zero units (`5000` → `5s`, `90000` → `1m 30s`) and floors sub-second, negative, and + * non-finite values (`NaN`/`Infinity`, e.g. from `convertToMS('abc')`) to `0s`. Roughly the inverse + * of {@link convertToMS}, but caps at days — years/months are ambiguous spans (leap years, 30-day + * months) and not meaningful for the elapsed-time readouts this serves. + */ +export function prettyDuration(ms: number): string { + if (!Number.isFinite(ms)) return '0s'; + let seconds = Math.max(0, Math.floor(ms / 1000)); + const days = Math.floor(seconds / 86400); + seconds %= 86400; + const hours = Math.floor(seconds / 3600); + seconds %= 3600; + const minutes = Math.floor(seconds / 60); + seconds %= 60; + const units: [number, string][] = [ + [days, 'd'], + [hours, 'h'], + [minutes, 'm'], + [seconds, 's'], + ]; + const firstIdx = units.findIndex(([value]) => value > 0); + const start = firstIdx === -1 ? units.length - 1 : firstIdx; + return units + .slice(start) + .map(([value, unit]) => `${value}${unit}`) + .join(' '); +} import * as hdbErrors from './errors/commonErrors.ts'; diff --git a/utility/environment/systemInformation.ts b/utility/environment/systemInformation.ts index 657a8daa98..8048cc7da0 100644 --- a/utility/environment/systemInformation.ts +++ b/utility/environment/systemInformation.ts @@ -63,13 +63,16 @@ export class SystemInformationResponse { } } -type TimeData = si.Systeminformation.TimeData; +type TimeData = si.Systeminformation.TimeData & { process_uptime: number }; /** - * Returns the current local time, uptime, timezone, and timezone name. + * Returns the current local time, timezone, and two uptimes in seconds: `uptime` (host uptime, from + * `si.time()`) and `process_uptime` (this Harper process's uptime). `process.uptime()` is accurate + * here because this operation runs in the Harper server process — unlike `harper status`, a separate + * CLI process. */ export function getTimeInfo(): TimeData { - return si.time(); + return { ...si.time(), process_uptime: Math.round(process.uptime()) }; } type CpuInfo = Pick<