From f4574dca5d8984f324d8f6e2fbb380532ddb7d57 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Tue, 18 Aug 2026 15:56:18 -0500 Subject: [PATCH 1/5] Add process uptime to `harper status` `harper status` now reports the running process uptime alongside status and pid, derived from the OS process start time already gathered by `getHDBProcessInfo()` (not `process.uptime()`, which would report the short-lived CLI's own age). Adds a reusable `prettyDuration(ms)` helper in common_utils as the rough inverse of `convertToMS`, and drops the `warn` on the normal stopped path (clean shutdown removes hdb.pid). Refs #2207 Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/status.ts | 36 +++++++++++++++++++++----- unitTests/bin/status.test.js | 32 +++++++++++++++++------ unitTests/utility/common_utils.test.js | 22 ++++++++++++++++ utility/common_utils.ts | 30 +++++++++++++++++++++ 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/bin/status.ts b/bin/status.ts index 5c1d15c16b..d0c0f6db46 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,17 @@ let hdbRoot; export default status; +/** 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,8 +43,7 @@ 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); @@ -41,10 +52,9 @@ async function status() { hdbPid = Number.parseInt(await fs.readFile(path.join(hdbRoot, hdbTerms.HDB_PID_FILE), '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 +66,22 @@ 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. Asking the server for its real uptime over the operations API + // would drag auth and a network round-trip into a command meant to stay lightweight and + // credential-free. Instead we use the OS process start time systeminformation already + // gathered — a local-wall-clock string like "2026-08-18 10:47:25". `Date.parse` reads it + // as local time and returns NaN (rather than throwing) if it's missing or unparseable, so + // guard explicitly and omit uptime rather than emitting "NaNs". + const startedAt = Date.parse(proc.started); + if (Number.isNaN(startedAt)) { + hdbLog.warn(`\`harperdb status\` could not determine uptime from process start time: ${proc.started}`); + } else { + status.harperdb.uptime = Math.max(0, Math.round(Date.now() - startedAt)); + } 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..e7801380b9 100644 --- a/unitTests/bin/status.test.js +++ b/unitTests/bin/status.test.js @@ -15,21 +15,27 @@ describe('Test status module', () => { let console_log_stub; let get_hdb_process_info_stub; + const NOW = 1_700_000_000_000; + const UPTIME_MS = 97_702_000; // 1d 3h 8m 22s + + // `proc.started` is a local-wall-clock string ("YYYY-MM-DD HH:MM:SS") that status parses with + // Date.parse (local time). Build it from local components of NOW - UPTIME_MS so the round-trip is + // exact regardless of the machine's timezone. + const pad = (n) => String(n).padStart(2, '0'); + const started_date = new Date(NOW - UPTIME_MS); + const STARTED_STR = + `${started_date.getFullYear()}-${pad(started_date.getMonth() + 1)}-${pad(started_date.getDate())} ` + + `${pad(started_date.getHours())}:${pad(started_date.getMinutes())}:${pad(started_date.getSeconds())}`; + const fake_hdb_process_info = { - core: [ - { - pid: 62076, - }, - { - pid: 55297, - }, - ], + core: [{ pid: 62076, started: STARTED_STR }, { 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'); + sandbox.stub(Date, 'now').returns(NOW); get_hdb_process_info_stub = sandbox.stub(sys_info, 'getHDBProcessInfo').resolves(fake_hdb_process_info); sandbox.stub(installation, 'isHdbInstalled').returns(true); }); @@ -40,10 +46,20 @@ describe('Test status module', () => { afterEach(() => { sandbox.resetHistory(); + get_hdb_process_info_stub.resolves(fake_hdb_process_info); }); it('Test status is returned as expected', async () => { const process_exit_stub = sandbox.stub(process, 'exit'); + await status(); + process_exit_stub.restore(); + expect(console_log_stub.args[0][0]).to.eql('harperdb:\n status: running\n pid: 62076\n uptime: 1d 3h 8m 22s\n'); + }); + + it('Test status omits uptime when process start time is unparseable but still reports running + pid', async () => { + const process_exit_stub = sandbox.stub(process, 'exit'); + get_hdb_process_info_stub.resolves({ core: [{ pid: 62076, started: 'not-a-date' }] }); + await status(); process_exit_stub.restore(); expect(console_log_stub.args[0][0]).to.eql('harperdb:\n status: running\n pid: 62076\n'); diff --git a/unitTests/utility/common_utils.test.js b/unitTests/utility/common_utils.test.js index 1f3f25a978..013ea3c21f 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', () => { + expect(cu.prettyDuration(5000)).to.equal('5s'); + expect(cu.prettyDuration(90000)).to.equal('1m 30s'); + expect(cu.prettyDuration(97702000)).to.equal('1d 3h 8m 22s'); + }); + it('keeps interior zero units once a larger unit is present', () => { + expect(cu.prettyDuration(86405000)).to.equal('1d 0h 0m 5s'); + }); + it('floors sub-second and non-positive values to 0s', () => { + expect(cu.prettyDuration(0)).to.equal('0s'); + expect(cu.prettyDuration(999)).to.equal('0s'); + expect(cu.prettyDuration(-5000)).to.equal('0s'); + }); + it('floors non-finite values to 0s', () => { + expect(cu.prettyDuration(NaN)).to.equal('0s'); + expect(cu.prettyDuration(Infinity)).to.equal('0s'); + expect(cu.prettyDuration(-Infinity)).to.equal('0s'); + expect(cu.prettyDuration(cu.convertToMS('abc'))).to.equal('0s'); + }); + }); + describe('Test httpRequest timeout', () => { const http = require('node:http'); let server, port; 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'; From 18376a5030a66badd19d3bf699608581eeac872f Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 19 Aug 2026 21:54:39 -0500 Subject: [PATCH 2/5] Add process_uptime to system_information time response Studio and other operations-API clients need Harper's process uptime, not just the host `uptime` (os.uptime) that si.time() already returns. Add `process_uptime` (ms) to the `time` attribute via process.uptime(), which is accurate here because the system_information operation runs in the Harper server process (unlike `harper status`, a separate CLI process). Co-Authored-By: Claude Opus 4.8 (1M context) --- unitTests/utility/environment/systemInformation.test.js | 4 +++- utility/environment/systemInformation.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/unitTests/utility/environment/systemInformation.test.js b/unitTests/utility/environment/systemInformation.test.js index 5a65e291f2..6c473e6616 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,8 @@ describe('test systemInformation module', () => { it('test getTimeInfo function', () => { const results = system_information.getTimeInfo(); assert.deepEqual(Object.keys(results).sort(), EXPECTED_PROPERTIES.time.sort()); + assert.equal(typeof results.process_uptime, 'number'); + assert.ok(results.process_uptime >= 0, 'process_uptime should be non-negative'); }); it('test getCPUInfo function', async () => { diff --git a/utility/environment/systemInformation.ts b/utility/environment/systemInformation.ts index 657a8daa98..4f51b2f842 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: `uptime` (host uptime, seconds, from + * `si.time()`) and `process_uptime` (this Harper process's uptime, milliseconds). `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() * 1000) }; } type CpuInfo = Pick< From 486f08dcad6cb2afc5c09853a3ea330b2882c657 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 19 Aug 2026 22:04:01 -0500 Subject: [PATCH 3/5] =?UTF-8?q?test:=20address=20review=20=E2=80=94=20extr?= =?UTF-8?q?act=20processUptimeMs,=20drop=20Sinon=20Date=20stub,=20use=20no?= =?UTF-8?q?de:assert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR feedback: move the deterministic uptime derivation behind a real boundary (`processUptimeMs(started, now)`) so it can be unit-tested with plain node:assert instead of a global Date.now stub. The status flow test now asserts structure (running/pid/uptime present); exact derivation is covered by processUptimeMs cases. Convert the new prettyDuration assertions from Chai to node:assert to match the house test style. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/status.ts | 24 ++++++++--- unitTests/bin/status.test.js | 59 ++++++++++++++++---------- unitTests/utility/common_utils.test.js | 22 +++++----- 3 files changed, 64 insertions(+), 41 deletions(-) diff --git a/bin/status.ts b/bin/status.ts index d0c0f6db46..acc812fb6c 100644 --- a/bin/status.ts +++ b/bin/status.ts @@ -23,6 +23,18 @@ let hdbRoot; export default status; +/** + * Derive process uptime in ms from an OS process start time and a reference `now`. `started` is the + * local-wall-clock string systeminformation reports (e.g. "2026-08-18 10:47:25"); `Date.parse` reads + * it as local time and returns NaN (rather than throwing) when it's missing or unparseable, so we + * return undefined in that case rather than emitting "NaNs". Negative spans clamp to 0. + */ +export function processUptimeMs(started: string, nowMs: number): number | undefined { + const startedAt = Date.parse(started); + if (Number.isNaN(startedAt)) return undefined; + return Math.max(0, Math.round(nowMs - startedAt)); +} + /** Format uptime, then print the status object as YAML. */ function report(status: any): void { if (typeof status.harperdb.uptime === 'number') { @@ -69,15 +81,13 @@ async function status() { // `status` is a separate short-lived CLI process, so `process.uptime()` would report its // own age, not the server's. Asking the server for its real uptime over the operations API // would drag auth and a network round-trip into a command meant to stay lightweight and - // credential-free. Instead we use the OS process start time systeminformation already - // gathered — a local-wall-clock string like "2026-08-18 10:47:25". `Date.parse` reads it - // as local time and returns NaN (rather than throwing) if it's missing or unparseable, so - // guard explicitly and omit uptime rather than emitting "NaNs". - const startedAt = Date.parse(proc.started); - if (Number.isNaN(startedAt)) { + // credential-free. Instead derive it from the OS process start time systeminformation + // already gathered. + const uptime = processUptimeMs(proc.started, Date.now()); + if (uptime === undefined) { hdbLog.warn(`\`harperdb status\` could not determine uptime from process start time: ${proc.started}`); } else { - status.harperdb.uptime = Math.max(0, Math.round(Date.now() - startedAt)); + status.harperdb.uptime = uptime; } break; } diff --git a/unitTests/bin/status.test.js b/unitTests/bin/status.test.js index e7801380b9..81424679e9 100644 --- a/unitTests/bin/status.test.js +++ b/unitTests/bin/status.test.js @@ -1,41 +1,50 @@ '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', () => { + // ISO-8601 UTC parses to a fixed instant regardless of the test machine's timezone. + const started = '2023-11-14T22:00:00.000Z'; + + it('derives uptime in ms from the start time and now', () => { + assert.strictEqual(processUptimeMs(started, Date.parse(started) + 97_702_000), 97_702_000); + }); + + it('rounds to the nearest ms', () => { + assert.strictEqual(processUptimeMs(started, Date.parse(started) + 1500.6), 1501); + }); + + it('clamps a future start time to 0', () => { + assert.strictEqual(processUptimeMs(started, Date.parse(started) - 5000), 0); + }); + + it('returns undefined for an unparseable start time', () => { + assert.strictEqual(processUptimeMs('not-a-date', 1_700_000_000_000), undefined); + }); +}); describe('Test status module', () => { const sandbox = sinon.createSandbox(); let console_log_stub; let get_hdb_process_info_stub; - const NOW = 1_700_000_000_000; - const UPTIME_MS = 97_702_000; // 1d 3h 8m 22s - - // `proc.started` is a local-wall-clock string ("YYYY-MM-DD HH:MM:SS") that status parses with - // Date.parse (local time). Build it from local components of NOW - UPTIME_MS so the round-trip is - // exact regardless of the machine's timezone. - const pad = (n) => String(n).padStart(2, '0'); - const started_date = new Date(NOW - UPTIME_MS); - const STARTED_STR = - `${started_date.getFullYear()}-${pad(started_date.getMonth() + 1)}-${pad(started_date.getDate())} ` + - `${pad(started_date.getHours())}:${pad(started_date.getMinutes())}:${pad(started_date.getSeconds())}`; - const fake_hdb_process_info = { - core: [{ pid: 62076, started: STARTED_STR }, { pid: 55297 }], + core: [{ pid: 62076, started: '2024-01-01 00:00:00' }, { 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'); - sandbox.stub(Date, 'now').returns(NOW); get_hdb_process_info_stub = sandbox.stub(sys_info, 'getHDBProcessInfo').resolves(fake_hdb_process_info); sandbox.stub(installation, 'isHdbInstalled').returns(true); }); @@ -49,28 +58,32 @@ describe('Test status module', () => { get_hdb_process_info_stub.resolves(fake_hdb_process_info); }); - it('Test status is returned as expected', async () => { + it('reports running, pid, and a formatted uptime', async () => { const process_exit_stub = sandbox.stub(process, 'exit'); await status(); process_exit_stub.restore(); - expect(console_log_stub.args[0][0]).to.eql('harperdb:\n status: running\n pid: 62076\n uptime: 1d 3h 8m 22s\n'); + 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('Test status omits uptime when process start time is unparseable but still reports running + pid', async () => { + it('omits uptime when the process start time is unparseable but still reports running + pid', async () => { const process_exit_stub = sandbox.stub(process, 'exit'); get_hdb_process_info_stub.resolves({ core: [{ pid: 62076, started: 'not-a-date' }] }); 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 () => { + it('reports stopped when nothing is running', async () => { const process_exit_stub = sandbox.stub(process, 'exit'); 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 013ea3c21f..d4f56563b0 100644 --- a/unitTests/utility/common_utils.test.js +++ b/unitTests/utility/common_utils.test.js @@ -588,23 +588,23 @@ describe('Test common_utils module', () => { describe('Test prettyDuration', () => { it('drops only leading zero units', () => { - expect(cu.prettyDuration(5000)).to.equal('5s'); - expect(cu.prettyDuration(90000)).to.equal('1m 30s'); - expect(cu.prettyDuration(97702000)).to.equal('1d 3h 8m 22s'); + 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', () => { - expect(cu.prettyDuration(86405000)).to.equal('1d 0h 0m 5s'); + assert.strictEqual(cu.prettyDuration(86405000), '1d 0h 0m 5s'); }); it('floors sub-second and non-positive values to 0s', () => { - expect(cu.prettyDuration(0)).to.equal('0s'); - expect(cu.prettyDuration(999)).to.equal('0s'); - expect(cu.prettyDuration(-5000)).to.equal('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', () => { - expect(cu.prettyDuration(NaN)).to.equal('0s'); - expect(cu.prettyDuration(Infinity)).to.equal('0s'); - expect(cu.prettyDuration(-Infinity)).to.equal('0s'); - expect(cu.prettyDuration(cu.convertToMS('abc'))).to.equal('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'); }); }); From 421f29edfd43002953fa41c2dbf094009b94a5a8 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 19 Aug 2026 22:28:30 -0500 Subject: [PATCH 4/5] Report process_uptime in seconds; drop obsolete process.exit stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review flagged that time.process_uptime (ms) sat next to time.uptime (seconds) in the same object — a 1000x unit footgun. Report process_uptime in seconds to match its sibling; uptime needs no sub-second precision. Strengthen the test to pin the seconds unit against process.uptime(). Also remove the now-obsolete process.exit sinon stubs in status.test.js — status() sets process.exitCode and no longer calls exit. Co-Authored-By: Claude Opus 4.8 (1M context) --- unitTests/bin/status.test.js | 6 ------ .../utility/environment/systemInformation.test.js | 6 +++++- utility/environment/systemInformation.ts | 10 +++++----- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/unitTests/bin/status.test.js b/unitTests/bin/status.test.js index 81424679e9..3649b50f61 100644 --- a/unitTests/bin/status.test.js +++ b/unitTests/bin/status.test.js @@ -59,9 +59,7 @@ describe('Test status module', () => { }); it('reports running, pid, and a formatted uptime', async () => { - const process_exit_stub = sandbox.stub(process, 'exit'); await status(); - process_exit_stub.restore(); const output = console_log_stub.args[0][0]; assert.match(output, /status: running/); assert.match(output, /pid: 62076/); @@ -70,20 +68,16 @@ describe('Test status module', () => { }); it('omits uptime when the process start time is unparseable but still reports running + pid', async () => { - const process_exit_stub = sandbox.stub(process, 'exit'); get_hdb_process_info_stub.resolves({ core: [{ pid: 62076, started: 'not-a-date' }] }); await status(); - process_exit_stub.restore(); assert.strictEqual(console_log_stub.args[0][0], 'harperdb:\n status: running\n pid: 62076\n'); }); it('reports stopped when nothing is running', async () => { - const process_exit_stub = sandbox.stub(process, 'exit'); get_hdb_process_info_stub.resolves({ core: [] }); await status(); - process_exit_stub.restore(); assert.strictEqual(console_log_stub.args[0][0], 'harperdb:\n status: stopped\n'); }); }); diff --git a/unitTests/utility/environment/systemInformation.test.js b/unitTests/utility/environment/systemInformation.test.js index 6c473e6616..0b2c92fe83 100644 --- a/unitTests/utility/environment/systemInformation.test.js +++ b/unitTests/utility/environment/systemInformation.test.js @@ -253,8 +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(results.process_uptime >= 0, 'process_uptime should be non-negative'); + 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/environment/systemInformation.ts b/utility/environment/systemInformation.ts index 4f51b2f842..8048cc7da0 100644 --- a/utility/environment/systemInformation.ts +++ b/utility/environment/systemInformation.ts @@ -66,13 +66,13 @@ export class SystemInformationResponse { type TimeData = si.Systeminformation.TimeData & { process_uptime: number }; /** - * Returns the current local time, timezone, and two uptimes: `uptime` (host uptime, seconds, from - * `si.time()`) and `process_uptime` (this Harper process's uptime, milliseconds). `process.uptime()` - * is accurate here because this operation runs in the Harper server process — unlike `harper status`, - * a separate CLI process. + * 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(), process_uptime: Math.round(process.uptime() * 1000) }; + return { ...si.time(), process_uptime: Math.round(process.uptime()) }; } type CpuInfo = Pick< From a2250f38481a0e9b5d9def88da6ff0865b35f38e Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Thu, 20 Aug 2026 11:53:52 -0500 Subject: [PATCH 5/5] Source CLI uptime from pid-file mtime (epoch), not a localized start string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processUptimeMs parsed systeminformation's `proc.started`, a TZ-less local wall-clock string, then subtracted — a round-trip that drifts by an hour for any process spanning a DST transition. Nothing should deal in localized time here: uptime is a duration. Derive it from the pid-file mtime, an absolute epoch (UTC) timestamp, so `Date.now() - mtimeMs` is DST-independent. The operations-API path already used process.uptime() (a number), so both surfaces now work purely from epochs/durations and render at the edge. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/status.ts | 36 +++++++++++++++--------------------- unitTests/bin/status.test.js | 25 +++++++++++-------------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/bin/status.ts b/bin/status.ts index acc812fb6c..2b1eb849fe 100644 --- a/bin/status.ts +++ b/bin/status.ts @@ -23,16 +23,10 @@ let hdbRoot; export default status; -/** - * Derive process uptime in ms from an OS process start time and a reference `now`. `started` is the - * local-wall-clock string systeminformation reports (e.g. "2026-08-18 10:47:25"); `Date.parse` reads - * it as local time and returns NaN (rather than throwing) when it's missing or unparseable, so we - * return undefined in that case rather than emitting "NaNs". Negative spans clamp to 0. - */ -export function processUptimeMs(started: string, nowMs: number): number | undefined { - const startedAt = Date.parse(started); - if (Number.isNaN(startedAt)) return undefined; - return Math.max(0, Math.round(nowMs - startedAt)); +/** 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. */ @@ -59,9 +53,10 @@ async function 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) { // A missing pid file is the normal stopped state (clean shutdown removes it), not an error. @@ -78,16 +73,15 @@ 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. Asking the server for its real uptime over the operations API - // would drag auth and a network round-trip into a command meant to stay lightweight and - // credential-free. Instead derive it from the OS process start time systeminformation - // already gathered. - const uptime = processUptimeMs(proc.started, Date.now()); - if (uptime === undefined) { - hdbLog.warn(`\`harperdb status\` could not determine uptime from process start time: ${proc.started}`); - } else { - status.harperdb.uptime = uptime; + // `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; } diff --git a/unitTests/bin/status.test.js b/unitTests/bin/status.test.js index 3649b50f61..ff4a7fcc78 100644 --- a/unitTests/bin/status.test.js +++ b/unitTests/bin/status.test.js @@ -12,39 +12,35 @@ const status = status_module.default; const { processUptimeMs } = status_module; describe('processUptimeMs', () => { - // ISO-8601 UTC parses to a fixed instant regardless of the test machine's timezone. - const started = '2023-11-14T22:00:00.000Z'; - - it('derives uptime in ms from the start time and now', () => { - assert.strictEqual(processUptimeMs(started, Date.parse(started) + 97_702_000), 97_702_000); + 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(started, Date.parse(started) + 1500.6), 1501); + assert.strictEqual(processUptimeMs(0, 1500.6), 1501); }); it('clamps a future start time to 0', () => { - assert.strictEqual(processUptimeMs(started, Date.parse(started) - 5000), 0); - }); - - it('returns undefined for an unparseable start time', () => { - assert.strictEqual(processUptimeMs('not-a-date', 1_700_000_000_000), undefined); + 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, started: '2024-01-01 00:00:00' }, { 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); }); @@ -56,6 +52,7 @@ 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('reports running, pid, and a formatted uptime', async () => { @@ -67,8 +64,8 @@ describe('Test status module', () => { assert.match(output, /uptime: \S/); }); - it('omits uptime when the process start time is unparseable but still reports running + pid', async () => { - get_hdb_process_info_stub.resolves({ core: [{ pid: 62076, started: 'not-a-date' }] }); + 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(); assert.strictEqual(console_log_stub.args[0][0], 'harperdb:\n status: running\n pid: 62076\n');