@meri-imperiumi/signalk-triplogger 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.1] - 2026-09-10
6
+
7
+ ### Fixed
8
+ - Fix server crash when a log file is corrupt, empty, or not a JSON
9
+ object: parse errors are now caught inside the read callback instead of
10
+ escaping as an uncaught exception that killed signalk-server. The bad
11
+ file is backed up as `<name>.json.corrupt` and a fresh log is started
12
+ - Persist logs atomically (temp file + fsync + rename) so a crash or
13
+ power loss mid-write can no longer truncate log files
14
+
5
15
  ## [1.4.0] - 2026-09-09
6
16
 
7
17
  ### Added
package/Logger.js CHANGED
@@ -1,15 +1,24 @@
1
1
  const fs = require('fs');
2
+ const { promisify } = require('util');
3
+
4
+ const fsWriteFile = promisify(fs.writeFile);
5
+ const fsOpen = promisify(fs.open);
6
+ const fsFsync = promisify(fs.fsync);
7
+ const fsClose = promisify(fs.close);
8
+ const fsRename = promisify(fs.rename);
2
9
 
3
10
  function toNM(meters) {
4
11
  return (meters / 1852).toFixed(2);
5
12
  }
6
13
 
7
14
  class Logger {
8
- constructor(filename) {
15
+ constructor(filename, onError) {
9
16
  this.state = null;
10
17
  this.filename = filename;
18
+ this.onError = onError;
11
19
  this.reset();
12
20
  this.unsaved = false;
21
+ this.saves = Promise.resolve();
13
22
  }
14
23
 
15
24
  reset() {
@@ -70,18 +79,36 @@ class Logger {
70
79
  }
71
80
 
72
81
  save() {
82
+ // Serialize writes: concurrent saves writing to the same temp file
83
+ // could interleave, and an older snapshot could be renamed over a
84
+ // newer one. Each save runs only after the previous one settles.
85
+ const previous = this.saves;
86
+ this.saves = previous.then(
87
+ () => this.write(),
88
+ () => this.write(),
89
+ );
90
+ return this.saves;
91
+ }
92
+
93
+ async write() {
73
94
  if (!this.unsaved) {
74
- return Promise.resolve();
95
+ return;
75
96
  }
76
- return new Promise((resolve, reject) => {
77
- fs.writeFile(this.filename, JSON.stringify(this.log, null, 2), (err) => {
78
- if (err) {
79
- reject(err);
80
- return;
81
- }
82
- resolve();
83
- });
84
- });
97
+ const tmpFile = `${this.filename}.tmp`;
98
+ await fsWriteFile(tmpFile, JSON.stringify(this.log, null, 2));
99
+ // Flush the temp file to disk before renaming: without fsync, a
100
+ // power loss can persist the rename while the file's contents never
101
+ // made it to disk, recreating a truncated log
102
+ const fd = await fsOpen(tmpFile, 'r+');
103
+ try {
104
+ await fsFsync(fd);
105
+ } finally {
106
+ await fsClose(fd);
107
+ }
108
+ // Rename over an existing file is atomic: readers see either the
109
+ // old log or the new one, never a partially written one
110
+ await fsRename(tmpFile, this.filename);
111
+ this.unsaved = false;
85
112
  }
86
113
 
87
114
  exists() {
@@ -103,9 +130,29 @@ class Logger {
103
130
  reject(err);
104
131
  return;
105
132
  }
106
- this.log = JSON.parse(contents);
107
- this.unsaved = false;
108
- resolve();
133
+ try {
134
+ const parsed = JSON.parse(contents);
135
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
136
+ throw new Error('Not a JSON object');
137
+ }
138
+ this.log = parsed;
139
+ this.unsaved = false;
140
+ resolve();
141
+ } catch (parseError) {
142
+ // Corrupt (for example truncated by a crash mid-write) or empty
143
+ // log file. Back it up for manual recovery and start a fresh log
144
+ // instead of failing the whole plugin.
145
+ const backup = `${this.filename}.corrupt`;
146
+ fs.rename(this.filename, backup, () => {
147
+ this.reset();
148
+ if (this.onError) {
149
+ this.onError(
150
+ `Corrupt log file ${this.filename} (${parseError.message}). Backed up to ${backup}, starting a fresh log`,
151
+ );
152
+ }
153
+ resolve();
154
+ });
155
+ }
109
156
  });
110
157
  });
111
158
  }
package/index.js CHANGED
@@ -52,7 +52,10 @@ module.exports = (app) => {
52
52
  // Load new logs
53
53
  if (!logs[logName]) {
54
54
  // New log, or saved log?
55
- const log = new Logger(getLogPath(logName));
55
+ const log = new Logger(
56
+ getLogPath(logName),
57
+ (message) => app.error(message),
58
+ );
56
59
  logs[logName] = log;
57
60
  return log.exists()
58
61
  .then((exists) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-triplogger",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Log the length of the current trip",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Smoke tests for the Logger: persistence, atomic saves, and recovery
3
+ * from corrupt or empty log files.
4
+ * @file logger.test.js
5
+ */
6
+
7
+ const test = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+ const {
10
+ mkdtempSync,
11
+ readFileSync,
12
+ writeFileSync,
13
+ existsSync,
14
+ } = require('node:fs');
15
+ const { tmpdir } = require('node:os');
16
+ const { join } = require('node:path');
17
+
18
+ const Logger = require('../Logger');
19
+
20
+ /**
21
+ * Build a Logger with a fresh temp directory, a log filename inside it,
22
+ * and an error collector for the onError callback.
23
+ *
24
+ * @returns {{dir: string, filename: string, logger: Logger, errors: string[]}}
25
+ */
26
+ function makeLogger() {
27
+ const dir = mkdtempSync(join(tmpdir(), 'triplogger-logger-'));
28
+ const filename = join(dir, '2026-09-11.json');
29
+ const errors = [];
30
+ const logger = new Logger(filename, (message) => errors.push(message));
31
+ return {
32
+ dir,
33
+ filename,
34
+ logger,
35
+ errors,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Read a file in the logger's directory as JSON.
41
+ *
42
+ * @param {string} dir
43
+ * @param {string} name file name without directory
44
+ * @returns {object}
45
+ */
46
+ function readJson(dir, name) {
47
+ return JSON.parse(readFileSync(join(dir, name), 'utf-8'));
48
+ }
49
+
50
+ test('save persists the log as valid JSON and leaves no temp files', async () => {
51
+ const { dir, filename, logger } = makeLogger();
52
+ logger.setState('sailing');
53
+ logger.appendTrip(1000);
54
+ await logger.save();
55
+
56
+ const persisted = readJson(dir, '2026-09-11.json');
57
+ assert.strictEqual(persisted.total, 1000);
58
+ assert.strictEqual(persisted.states.sailing, 1000);
59
+ assert.ok(!existsSync(`${filename}.tmp`), 'temp file was left behind');
60
+ });
61
+
62
+ test('load restores a persisted log', async () => {
63
+ const { filename } = makeLogger();
64
+ const first = new Logger(filename);
65
+ first.setState('motoring');
66
+ first.appendTrip(500);
67
+ await first.save();
68
+
69
+ const errors = [];
70
+ const second = new Logger(filename, (m) => errors.push(m));
71
+ await second.load();
72
+ assert.strictEqual(second.toJSON().total, 500);
73
+ assert.strictEqual(second.toJSON().states.motoring, 500);
74
+ assert.strictEqual(errors.length, 0);
75
+ });
76
+
77
+ test('overlapping saves persist the newest state without corrupting the file', async () => {
78
+ const { dir, filename, logger } = makeLogger();
79
+ logger.appendTrip(1);
80
+ const first = logger.save();
81
+ // Mutate while the first save is still in flight
82
+ logger.appendTrip(2);
83
+ const second = logger.save();
84
+ await Promise.all([first, second]);
85
+
86
+ assert.strictEqual(readJson(dir, '2026-09-11.json').total, 3);
87
+ assert.ok(!existsSync(`${filename}.tmp`), 'temp file was left behind');
88
+ });
89
+
90
+ test('load recovers from a truncated log file by backing it up', async () => {
91
+ const {
92
+ dir,
93
+ filename,
94
+ logger,
95
+ errors,
96
+ } = makeLogger();
97
+ const corrupt = '{"started": "2026-09-11T00:00:00.000Z", "total": 50';
98
+ writeFileSync(filename, corrupt, 'utf-8');
99
+
100
+ await logger.load();
101
+
102
+ // The corrupt contents are preserved for manual recovery
103
+ assert.ok(existsSync(`${filename}.corrupt`), 'no .corrupt backup was made');
104
+ assert.strictEqual(readFileSync(`${filename}.corrupt`, 'utf-8'), corrupt);
105
+ // The corruption was reported
106
+ assert.ok(errors.length > 0, 'corruption was not reported via onError');
107
+ assert.match(errors[0], /corrupt/i);
108
+ // The logger works from a fresh log afterwards
109
+ assert.strictEqual(logger.toJSON().total, 0);
110
+ logger.appendTrip(100);
111
+ await logger.save();
112
+ assert.strictEqual(readJson(dir, '2026-09-11.json').total, 100);
113
+ });
114
+
115
+ test('load recovers from an empty log file', async () => {
116
+ const {
117
+ dir,
118
+ filename,
119
+ logger,
120
+ errors,
121
+ } = makeLogger();
122
+ writeFileSync(filename, '', 'utf-8');
123
+
124
+ await logger.load();
125
+
126
+ assert.ok(existsSync(`${filename}.corrupt`));
127
+ assert.ok(errors.length > 0, 'corruption was not reported via onError');
128
+ logger.appendTrip(100);
129
+ await logger.save();
130
+ assert.strictEqual(readJson(dir, '2026-09-11.json').total, 100);
131
+ });
132
+
133
+ test('load recovers from a log file containing a JSON scalar', async () => {
134
+ const { filename, logger, errors } = makeLogger();
135
+ writeFileSync(filename, 'null', 'utf-8');
136
+
137
+ await logger.load();
138
+
139
+ assert.ok(existsSync(`${filename}.corrupt`));
140
+ assert.ok(errors.length > 0, 'corruption was not reported via onError');
141
+ assert.strictEqual(logger.toJSON().total, 0);
142
+ });
143
+
144
+ test('load still rejects on read errors', async () => {
145
+ const { filename } = makeLogger();
146
+ const logger = new Logger(filename);
147
+ await assert.rejects(() => logger.load(), /ENOENT/);
148
+ });
@@ -471,6 +471,52 @@ test('standard paths send no meta messages', () => {
471
471
  plugin.stop();
472
472
  });
473
473
 
474
+ test('a corrupt persisted log is backed up and logging continues', async () => {
475
+ const app = new FakeSignalKApp();
476
+ // A log truncated by a crash mid-write. With the old code this threw
477
+ // an uncaught exception from the readFile callback, killing the whole
478
+ // server process on every startup.
479
+ const corrupt = '{"started": "2026-09-11T00:00:00.000Z", "total": 5000';
480
+ writeFileSync(join(app.dataPath, 'current.json'), corrupt, 'utf-8');
481
+ const plugin = makePlugin(app);
482
+ plugin.start({ totals: false });
483
+
484
+ emitDelta(app, {
485
+ context: 'vessels.self',
486
+ updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
487
+ });
488
+ emitDelta(app, {
489
+ context: 'vessels.self',
490
+ updates: [
491
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
492
+ ],
493
+ });
494
+ emitDelta(app, {
495
+ context: 'vessels.self',
496
+ updates: [
497
+ { values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
498
+ ],
499
+ });
500
+ await waitFor(() => app.messages
501
+ .flatMap(({ message }) => message.updates.flatMap((u) => u.values))
502
+ .some((v) => v.path === 'navigation.trip.log' && v.value > 0));
503
+ plugin.stop();
504
+
505
+ // The corrupt contents are preserved for manual recovery...
506
+ assert.strictEqual(
507
+ readFileSync(join(app.dataPath, 'current.json.corrupt'), 'utf-8'),
508
+ corrupt,
509
+ );
510
+ // ...and the corruption was reported
511
+ assert.ok(
512
+ app.errors.some((e) => /corrupt/i.test(e)),
513
+ `got ${JSON.stringify(app.errors)}`,
514
+ );
515
+ // Logging continues from a fresh log
516
+ const current = readLog(app, 'current');
517
+ assert.ok(current.total > 0, `current.total was ${current.total}`);
518
+ });
519
+
474
520
  test('stop clears subscriptions', () => {
475
521
  const { app, plugin } = makeStarted();
476
522
  assert.strictEqual(app.subscriptionmanager.subscriptions.length, 1);