dsh-pause-resume 0.1.0 → 0.3.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/lib/heartbeat.js CHANGED
@@ -1,95 +1,245 @@
1
- import { mkdirSync, openSync, closeSync, fsyncSync, readFileSync, renameSync, writeSync } from 'node:fs'
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 snapshot 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 on a
9
- * timer from the same list the button already reads, and only when the set changes.
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
- export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, now = () => Date.now(), log = () => {} }) {
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 last = ''
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 = ''
37
+
38
+ function noteError(message) {
39
+ try {
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 })
53
+ }
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
+ }
21
66
 
22
- function write(sessions) {
23
- const body = JSON.stringify({ version: 1, bootId, updatedAt: now(), sessions })
67
+ /** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
68
+ function syncDir(path) {
69
+ if (process.platform === 'win32') return // ui-copy-ignore
70
+ let fd = null
24
71
  try {
25
- mkdirSync(dir, { recursive: true })
26
- const tmp = `${file}.${process.pid}.tmp`
27
- const fd = openSync(tmp, 'w')
28
- try {
29
- writeSync(fd, body)
30
- fsyncSync(fd)
31
- } finally {
32
- closeSync(fd)
72
+ fd = fsImpl.openSync(path, 'r')
73
+ fsImpl.fsyncSync(fd)
74
+ } catch (error) {
75
+ // Some filesystems refuse a directory fsync after a rename that already landed. The record is
76
+ // published either way; only the power-loss guarantee is weaker, so this is not a failure.
77
+ const code = error?.code
78
+ if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOTSUP' && code !== 'EINVAL' && code !== 'ENOSYS' && code !== 'EBADF' && code !== 'EISDIR') {
79
+ noteError(`cannot flush ${path}: ${error?.message ?? error}`)
33
80
  }
34
- renameSync(tmp, file)
81
+ } finally {
82
+ if (fd !== null) closeQuietly(fsImpl, fd)
83
+ }
84
+ }
85
+
86
+ function persist(rows) {
87
+ let body = ''
88
+ try {
89
+ body = JSON.stringify({ version: 2, bootId, updatedAt: now(), sessions: rows })
90
+ } catch (error) {
91
+ noteError(`cannot record running sessions: ${error?.message ?? error}`)
92
+ return false
93
+ }
94
+ const tmp = `${file}.${process.pid}.${counter++}.tmp`
95
+ let fd = null
96
+ let renamed = false
97
+ try {
98
+ fsImpl.mkdirSync(dir, { recursive: true })
99
+ fd = fsImpl.openSync(tmp, 'w')
100
+ fsImpl.writeSync(fd, body)
101
+ fsImpl.fsyncSync(fd)
102
+ const opened = fd
103
+ fd = null
104
+ fsImpl.closeSync(opened)
105
+ fsImpl.renameSync(tmp, file)
106
+ renamed = true
107
+ syncDir(dir)
108
+ lastWritten = serialize(rows)
35
109
  return true
36
110
  } catch (error) {
37
- log(`cannot record running sessions: ${error?.message ?? error}`)
111
+ noteError(`cannot record running sessions: ${error?.message ?? error}`)
38
112
  return false
113
+ } finally {
114
+ if (fd !== null) closeQuietly(fsImpl, fd)
115
+ if (renamed === false) {
116
+ try {
117
+ fsImpl.rmSync(tmp, { force: true })
118
+ } catch {}
119
+ }
39
120
  }
40
121
  }
41
122
 
42
- /** Claims the previous process's heartbeat once, at start, before this process overwrites it. */
43
- function capture() {
123
+ /**
124
+ * Read the previous process's record without claiming it. The caller has to read this before
125
+ * `start()` overwrites the file, and turning it into a durable plan may take a moment: a read that
126
+ * also claimed would leave the very next read empty if that write failed.
127
+ */
128
+ function peek() {
129
+ if (residue !== null) return residue
130
+ let parsed = null
44
131
  try {
45
- const parsed = JSON.parse(readFileSync(file, 'utf8'))
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
132
+ parsed = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
52
133
  } catch {
53
134
  return null
54
135
  }
136
+ if (parsed === null || typeof parsed !== 'object') return null
137
+ if (typeof parsed.bootId === 'string' && parsed.bootId === bootId) return null
138
+ const sessions = normalize(parsed.sessions)
139
+ if (sessions.length === 0) return null
140
+ residue = { sessions, at: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null }
141
+ return residue
142
+ }
143
+
144
+ /** Claims the previous process's heartbeat exactly once, before this process overwrites it. */
145
+ function capture() {
146
+ if (captured) return residue
147
+ captured = true
148
+ return peek()
149
+ }
150
+
151
+ function writePending() {
152
+ return dirty() ? persist(pending) : true
153
+ }
154
+
155
+ /**
156
+ * Read the running set and record it. A listing that throws keeps the previous set; a dirty set
157
+ * left behind by a failed write is retried on the next tick.
158
+ */
159
+ function refresh(force = false) {
160
+ const apply = (ids) => {
161
+ if (ids !== null) pending = normalize(ids)
162
+ if (stopped === true && force !== true) return true
163
+ return writePending()
164
+ }
165
+ let value = null
166
+ try {
167
+ value = typeof listRunning === 'function' ? listRunning() : pending
168
+ } catch (error) {
169
+ noteError(`cannot list running sessions: ${error?.message ?? error}`)
170
+ return Promise.resolve(apply(null))
171
+ }
172
+ if (value !== null && typeof value?.then === 'function') {
173
+ return Promise.resolve(value).then(
174
+ (ids) => apply(ids),
175
+ (error) => {
176
+ noteError(`cannot list running sessions: ${error?.message ?? error}`)
177
+ return apply(null)
178
+ },
179
+ )
180
+ }
181
+ return Promise.resolve(apply(value))
182
+ }
183
+
184
+ /** Event hooks report a changed set: write once, after a burst, and never block the caller. */
185
+ function note(sessionIds) {
186
+ pending = normalize(sessionIds)
187
+ if (stopped === true || dirty() === false || noteTimer !== null) return
188
+ noteTimer = setTimeout(() => {
189
+ noteTimer = null
190
+ void flush()
191
+ }, DEBOUNCE_MS)
192
+ if (typeof noteTimer.unref === 'function') noteTimer.unref()
55
193
  }
56
194
 
57
- async function tick() {
58
- if (stopped || residue !== null) return
59
- let sessions
195
+ function flush() {
60
196
  try {
61
- sessions = await listRunning()
197
+ return Promise.resolve(writePending())
62
198
  } catch (error) {
63
- log(`cannot list running sessions: ${error?.message ?? error}`)
64
- return
199
+ noteError(`cannot record running sessions: ${error?.message ?? error}`)
200
+ return Promise.resolve(false)
65
201
  }
66
- const body = JSON.stringify(sessions.map((sessionId) => ({ sessionId })))
67
- if (body === last) return
68
- last = body
69
- write(sessions.map((sessionId) => ({ sessionId })))
70
202
  }
71
203
 
72
204
  function start() {
73
- if (stopped || timer !== null) return
205
+ if (started) return
206
+ started = true
207
+ stopped = false
74
208
  capture()
75
- void tick()
76
- timer = setInterval(() => void tick(), intervalMs)
209
+ void refresh()
210
+ timer = setInterval(() => {
211
+ void refresh()
212
+ }, intervalMs)
77
213
  if (typeof timer.unref === 'function') timer.unref()
78
214
  }
79
215
 
216
+ function stop() {
217
+ stopped = true
218
+ if (timer !== null) {
219
+ clearInterval(timer)
220
+ timer = null
221
+ }
222
+ if (noteTimer !== null) {
223
+ clearTimeout(noteTimer)
224
+ noteTimer = null
225
+ }
226
+ }
227
+
80
228
  /** The residue was restored: from here on the heartbeat describes this process again. */
81
229
  function release() {
82
230
  residue = null
83
- last = ''
84
- write([])
231
+ void refresh(true)
85
232
  }
86
233
 
87
- function stop() {
88
- stopped = true
89
- if (timer !== null) clearInterval(timer)
90
- timer = null
91
- if (residue === null) write([])
234
+ return {
235
+ note,
236
+ peek,
237
+ start,
238
+ stop,
239
+ release,
240
+ flush,
241
+ get residue() {
242
+ return residue
243
+ },
92
244
  }
93
-
94
- return { start, stop, release, get residue() { return residue } }
95
245
  }