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/lib/heartbeat.js CHANGED
@@ -1,95 +1,226 @@
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 = ''
21
37
 
22
- function write(sessions) {
23
- const body = JSON.stringify({ version: 1, bootId, updatedAt: now(), sessions })
38
+ function noteError(message) {
24
39
  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)
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
- renameSync(tmp, file)
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
- log(`cannot record running sessions: ${error?.message ?? error}`)
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, at start, before this process overwrites it. */
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
- 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
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
- async function tick() {
58
- if (stopped || residue !== null) return
59
- let sessions
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
- sessions = await listRunning()
149
+ value = typeof listRunning === 'function' ? listRunning() : pending
62
150
  } catch (error) {
63
- log(`cannot list running sessions: ${error?.message ?? error}`)
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 (stopped || timer !== null) return
187
+ if (started) return
188
+ started = true
189
+ stopped = false
74
190
  capture()
75
- void tick()
76
- timer = setInterval(() => void tick(), intervalMs)
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
- last = ''
84
- write([])
213
+ void refresh(true)
85
214
  }
86
215
 
87
- function stop() {
88
- stopped = true
89
- if (timer !== null) clearInterval(timer)
90
- timer = null
91
- if (residue === null) write([])
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
  }