dsh-pause-resume 0.1.0 → 0.2.0
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/README.md +67 -35
- package/client.js +101 -28
- package/index.js +102 -31
- package/lib/engine.js +235 -72
- package/lib/heartbeat.js +180 -49
- package/lib/probe.js +234 -113
- package/lib/snapshot.js +143 -78
- package/package.json +2 -2
package/lib/heartbeat.js
CHANGED
|
@@ -1,95 +1,226 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Crash residue without reading logs.
|
|
6
6
|
*
|
|
7
|
-
* A
|
|
8
|
-
* answers the other half: which sessions had a turn running a few seconds ago. It is written
|
|
9
|
-
*
|
|
7
|
+
* A plan only exists because a person pressed the button. A crash has no press, so this file
|
|
8
|
+
* answers the other half: which sessions had a turn running a few seconds ago. It is written from
|
|
9
|
+
* the same list the button already reads, coalesced, and only when the set changes.
|
|
10
10
|
*
|
|
11
11
|
* The file carries the id of the process that wrote it. The same process reloading the plugin (HMR)
|
|
12
12
|
* therefore sees its own heartbeat and nothing to restore, while a new process sees the previous
|
|
13
|
-
* one's and offers
|
|
13
|
+
* one's and offers resume. `stop()` deliberately leaves the last heartbeat in place: it is the
|
|
14
|
+
* evidence the next process reads.
|
|
14
15
|
*/
|
|
15
|
-
|
|
16
|
+
|
|
17
|
+
const DEBOUNCE_MS = 150
|
|
18
|
+
|
|
19
|
+
let counter = 0
|
|
20
|
+
|
|
21
|
+
function closeQuietly(fsv, fd) {
|
|
22
|
+
try {
|
|
23
|
+
fsv.closeSync(fd)
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, now = () => Date.now(), fs: fsImpl = fs, log = () => {} }) {
|
|
16
28
|
const file = join(dir, 'running.json')
|
|
17
29
|
let timer = null
|
|
18
|
-
let
|
|
30
|
+
let noteTimer = null
|
|
19
31
|
let residue = null
|
|
32
|
+
let captured = false
|
|
33
|
+
let started = false
|
|
20
34
|
let stopped = false
|
|
35
|
+
let pending = []
|
|
36
|
+
let lastWritten = ''
|
|
21
37
|
|
|
22
|
-
function
|
|
23
|
-
const body = JSON.stringify({ version: 1, bootId, updatedAt: now(), sessions })
|
|
38
|
+
function noteError(message) {
|
|
24
39
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
log(message)
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalize(ids) {
|
|
45
|
+
const rows = []
|
|
46
|
+
const seen = new Set()
|
|
47
|
+
if (Array.isArray(ids)) {
|
|
48
|
+
for (const item of ids) {
|
|
49
|
+
const sessionId = typeof item === 'string' ? item : item?.sessionId
|
|
50
|
+
if (typeof sessionId !== 'string' || sessionId === '' || seen.has(sessionId)) continue
|
|
51
|
+
seen.add(sessionId)
|
|
52
|
+
rows.push({ sessionId })
|
|
33
53
|
}
|
|
34
|
-
|
|
54
|
+
}
|
|
55
|
+
rows.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0))
|
|
56
|
+
return rows
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function serialize(rows) {
|
|
60
|
+
return JSON.stringify(rows)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dirty() {
|
|
64
|
+
return serialize(pending) !== lastWritten
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
|
|
68
|
+
function syncDir(path) {
|
|
69
|
+
let fd = null
|
|
70
|
+
try {
|
|
71
|
+
fd = fsImpl.openSync(path, 'r')
|
|
72
|
+
fsImpl.fsyncSync(fd)
|
|
73
|
+
} finally {
|
|
74
|
+
if (fd !== null) closeQuietly(fsImpl, fd)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function persist(rows) {
|
|
79
|
+
let body = ''
|
|
80
|
+
try {
|
|
81
|
+
body = JSON.stringify({ version: 2, bootId, updatedAt: now(), sessions: rows })
|
|
82
|
+
} catch (error) {
|
|
83
|
+
noteError(`cannot record running sessions: ${error?.message ?? error}`)
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
const tmp = `${file}.${process.pid}.${counter++}.tmp`
|
|
87
|
+
let fd = null
|
|
88
|
+
let renamed = false
|
|
89
|
+
try {
|
|
90
|
+
fsImpl.mkdirSync(dir, { recursive: true })
|
|
91
|
+
fd = fsImpl.openSync(tmp, 'w')
|
|
92
|
+
fsImpl.writeSync(fd, body)
|
|
93
|
+
fsImpl.fsyncSync(fd)
|
|
94
|
+
const opened = fd
|
|
95
|
+
fd = null
|
|
96
|
+
fsImpl.closeSync(opened)
|
|
97
|
+
fsImpl.renameSync(tmp, file)
|
|
98
|
+
renamed = true
|
|
99
|
+
syncDir(dir)
|
|
100
|
+
lastWritten = serialize(rows)
|
|
35
101
|
return true
|
|
36
102
|
} catch (error) {
|
|
37
|
-
|
|
103
|
+
noteError(`cannot record running sessions: ${error?.message ?? error}`)
|
|
38
104
|
return false
|
|
105
|
+
} finally {
|
|
106
|
+
if (fd !== null) closeQuietly(fsImpl, fd)
|
|
107
|
+
if (renamed === false) {
|
|
108
|
+
try {
|
|
109
|
+
fsImpl.rmSync(tmp, { force: true })
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
39
112
|
}
|
|
40
113
|
}
|
|
41
114
|
|
|
42
|
-
/** Claims the previous process's heartbeat once,
|
|
115
|
+
/** Claims the previous process's heartbeat exactly once, before this process overwrites it. */
|
|
43
116
|
function capture() {
|
|
117
|
+
if (captured) return residue
|
|
118
|
+
captured = true
|
|
119
|
+
let parsed = null
|
|
44
120
|
try {
|
|
45
|
-
|
|
46
|
-
if (parsed === null || typeof parsed !== 'object' || parsed.version !== 1) return null
|
|
47
|
-
if (typeof parsed.bootId === 'string' && parsed.bootId === bootId) return null
|
|
48
|
-
const sessions = Array.isArray(parsed.sessions) ? parsed.sessions.filter((row) => typeof row?.sessionId === 'string') : []
|
|
49
|
-
if (sessions.length === 0) return null
|
|
50
|
-
residue = { sessions, at: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null }
|
|
51
|
-
return residue
|
|
121
|
+
parsed = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
|
|
52
122
|
} catch {
|
|
53
123
|
return null
|
|
54
124
|
}
|
|
125
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
126
|
+
if (typeof parsed.bootId === 'string' && parsed.bootId === bootId) return null
|
|
127
|
+
const sessions = normalize(parsed.sessions)
|
|
128
|
+
if (sessions.length === 0) return null
|
|
129
|
+
residue = { sessions, at: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null }
|
|
130
|
+
return residue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writePending() {
|
|
134
|
+
return dirty() ? persist(pending) : true
|
|
55
135
|
}
|
|
56
136
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Read the running set and record it. A listing that throws keeps the previous set; a dirty set
|
|
139
|
+
* left behind by a failed write is retried on the next tick.
|
|
140
|
+
*/
|
|
141
|
+
function refresh(force = false) {
|
|
142
|
+
const apply = (ids) => {
|
|
143
|
+
if (ids !== null) pending = normalize(ids)
|
|
144
|
+
if (stopped === true && force !== true) return true
|
|
145
|
+
return writePending()
|
|
146
|
+
}
|
|
147
|
+
let value = null
|
|
60
148
|
try {
|
|
61
|
-
|
|
149
|
+
value = typeof listRunning === 'function' ? listRunning() : pending
|
|
62
150
|
} catch (error) {
|
|
63
|
-
|
|
64
|
-
return
|
|
151
|
+
noteError(`cannot list running sessions: ${error?.message ?? error}`)
|
|
152
|
+
return Promise.resolve(apply(null))
|
|
153
|
+
}
|
|
154
|
+
if (value !== null && typeof value?.then === 'function') {
|
|
155
|
+
return Promise.resolve(value).then(
|
|
156
|
+
(ids) => apply(ids),
|
|
157
|
+
(error) => {
|
|
158
|
+
noteError(`cannot list running sessions: ${error?.message ?? error}`)
|
|
159
|
+
return apply(null)
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
return Promise.resolve(apply(value))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Event hooks report a changed set: write once, after a burst, and never block the caller. */
|
|
167
|
+
function note(sessionIds) {
|
|
168
|
+
pending = normalize(sessionIds)
|
|
169
|
+
if (stopped === true || dirty() === false || noteTimer !== null) return
|
|
170
|
+
noteTimer = setTimeout(() => {
|
|
171
|
+
noteTimer = null
|
|
172
|
+
void flush()
|
|
173
|
+
}, DEBOUNCE_MS)
|
|
174
|
+
if (typeof noteTimer.unref === 'function') noteTimer.unref()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function flush() {
|
|
178
|
+
try {
|
|
179
|
+
return Promise.resolve(writePending())
|
|
180
|
+
} catch (error) {
|
|
181
|
+
noteError(`cannot record running sessions: ${error?.message ?? error}`)
|
|
182
|
+
return Promise.resolve(false)
|
|
65
183
|
}
|
|
66
|
-
const body = JSON.stringify(sessions.map((sessionId) => ({ sessionId })))
|
|
67
|
-
if (body === last) return
|
|
68
|
-
last = body
|
|
69
|
-
write(sessions.map((sessionId) => ({ sessionId })))
|
|
70
184
|
}
|
|
71
185
|
|
|
72
186
|
function start() {
|
|
73
|
-
if (
|
|
187
|
+
if (started) return
|
|
188
|
+
started = true
|
|
189
|
+
stopped = false
|
|
74
190
|
capture()
|
|
75
|
-
void
|
|
76
|
-
timer = setInterval(() =>
|
|
191
|
+
void refresh()
|
|
192
|
+
timer = setInterval(() => {
|
|
193
|
+
void refresh()
|
|
194
|
+
}, intervalMs)
|
|
77
195
|
if (typeof timer.unref === 'function') timer.unref()
|
|
78
196
|
}
|
|
79
197
|
|
|
198
|
+
function stop() {
|
|
199
|
+
stopped = true
|
|
200
|
+
if (timer !== null) {
|
|
201
|
+
clearInterval(timer)
|
|
202
|
+
timer = null
|
|
203
|
+
}
|
|
204
|
+
if (noteTimer !== null) {
|
|
205
|
+
clearTimeout(noteTimer)
|
|
206
|
+
noteTimer = null
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
80
210
|
/** The residue was restored: from here on the heartbeat describes this process again. */
|
|
81
211
|
function release() {
|
|
82
212
|
residue = null
|
|
83
|
-
|
|
84
|
-
write([])
|
|
213
|
+
void refresh(true)
|
|
85
214
|
}
|
|
86
215
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
216
|
+
return {
|
|
217
|
+
note,
|
|
218
|
+
start,
|
|
219
|
+
stop,
|
|
220
|
+
release,
|
|
221
|
+
flush,
|
|
222
|
+
get residue() {
|
|
223
|
+
return residue
|
|
224
|
+
},
|
|
92
225
|
}
|
|
93
|
-
|
|
94
|
-
return { start, stop, release, get residue() { return residue } }
|
|
95
226
|
}
|