@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 +10 -0
- package/Logger.js +61 -14
- package/index.js +4 -1
- package/package.json +1 -1
- package/tests/logger.test.js +148 -0
- package/tests/plugin.test.js +46 -0
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
|
|
95
|
+
return;
|
|
75
96
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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
|
@@ -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
|
+
});
|
package/tests/plugin.test.js
CHANGED
|
@@ -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);
|