@knpkv/jira-clockify 1.2.1 → 1.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/README.md +9 -2
- package/dist/package.json +1 -1
- package/dist/src/cli/timer/status.js +110 -71
- package/dist/src/cli/timer/status.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/nvim/lua/jcf/state.lua +191 -13
- package/package.json +1 -1
package/nvim/lua/jcf/state.lua
CHANGED
|
@@ -2,8 +2,17 @@ local M = {}
|
|
|
2
2
|
local uv = vim.loop or vim.uv
|
|
3
3
|
local cached = { active = false }
|
|
4
4
|
local last_key = nil
|
|
5
|
+
local last_path = nil
|
|
5
6
|
local poll_timer = nil
|
|
6
7
|
local poll_job = nil
|
|
8
|
+
-- Config captured by `start_poll`, so the command and its lock/stamp paths stay
|
|
9
|
+
-- stable for the lifetime of the timer.
|
|
10
|
+
local poll_config = nil
|
|
11
|
+
-- The interval `start_poll` was given. Needed away from that function because it
|
|
12
|
+
-- is also the machine's poll budget, not just this editor's tick spacing.
|
|
13
|
+
local poll_interval_ms = nil
|
|
14
|
+
local poll_once
|
|
15
|
+
local arm_poll
|
|
7
16
|
|
|
8
17
|
-- The cache key has to change on every write. `mtime.sec` alone does not: a
|
|
9
18
|
-- write landing in the same filesystem second as the previous read is
|
|
@@ -30,12 +39,14 @@ function M.read(state_path)
|
|
|
30
39
|
-- spawning `jcf timer status` every tick against a timer that isn't there.
|
|
31
40
|
cached = { active = false }
|
|
32
41
|
last_key = nil
|
|
42
|
+
last_path = nil
|
|
33
43
|
return cached
|
|
34
44
|
end
|
|
35
45
|
local key = stat_key(stat)
|
|
36
|
-
if key == last_key then
|
|
46
|
+
if path == last_path and key == last_key then
|
|
37
47
|
return cached
|
|
38
48
|
end
|
|
49
|
+
last_path = path
|
|
39
50
|
last_key = key
|
|
40
51
|
local f = io.open(path, "r")
|
|
41
52
|
if f then
|
|
@@ -57,7 +68,9 @@ end
|
|
|
57
68
|
-- multiplied by the number of open editors. Hence: at most one job in flight,
|
|
58
69
|
-- owned by nvim (never detached, so VimLeave and jobstop can actually kill it),
|
|
59
70
|
-- under a watchdog. When no timer is running locally there is nothing to
|
|
60
|
-
-- reconcile, so the spawn is skipped entirely.
|
|
71
|
+
-- reconcile, so the spawn is skipped entirely. That bounds one editor to one
|
|
72
|
+
-- poll; the lock below bounds the whole machine to one, which is the part that
|
|
73
|
+
-- actually decides the cost when many editors are open.
|
|
61
74
|
-- Backstop only. `jcf timer status` bounds every network call it can make, but
|
|
62
75
|
-- not all at the same value: an expired Jira token costs up to
|
|
63
76
|
-- `REFRESH_TIMEOUT` (30s, in @knpkv/jira-cli's JiraAuth) before any command
|
|
@@ -88,19 +101,139 @@ local POLL_TIMEOUT_MS = 120000
|
|
|
88
101
|
-- `invalid_grant`. No client-side design can close that window.
|
|
89
102
|
local POLL_STOP_GRACE_MS = 35000
|
|
90
103
|
|
|
104
|
+
-- ---------------------------------------------------------------------------
|
|
105
|
+
-- Cross-editor lock: one `jcf timer status` per machine, not per editor.
|
|
106
|
+
-- ---------------------------------------------------------------------------
|
|
107
|
+
--
|
|
108
|
+
-- The single-flight guard above is process-local. It bounds one nvim to one
|
|
109
|
+
-- poll and says nothing about the others, so with an editor open per project it
|
|
110
|
+
-- does not bound anything that matters: 19 editors on a 30s timer is 19 cold
|
|
111
|
+
-- Node starts every 30s, each making the same four Clockify calls to reconcile
|
|
112
|
+
-- the same single global timer.
|
|
113
|
+
--
|
|
114
|
+
-- The work is inherently shared — one `~/.jcf/state.json`, one running timer —
|
|
115
|
+
-- so exactly one editor should do it and the rest should read the file it
|
|
116
|
+
-- refreshes, which `M.read` already does off an mtime cache. Coordination uses
|
|
117
|
+
-- util-linux `flock`: every editor starts a non-blocking contender, the kernel
|
|
118
|
+
-- lets exactly one exec `jcf`, and every loser exits without starting Node.
|
|
119
|
+
-- `--no-fork` leaves the lock attached to the `jcf` process itself. If nvim is
|
|
120
|
+
-- SIGKILLed, its child therefore keeps the lock until the poll really exits.
|
|
121
|
+
--
|
|
122
|
+
-- The lock is half of it. It bounds how many polls run *at once*, and because
|
|
123
|
+
-- it is released as soon as the CLI exits, it says nothing about how often they
|
|
124
|
+
-- run — 19 editors would still spawn ~19 times per interval, just never two at
|
|
125
|
+
-- the same moment. The poll stamp below is what bounds the rate, and the two
|
|
126
|
+
-- together are what make the "per machine" claim above true.
|
|
127
|
+
--
|
|
128
|
+
-- No directory is created for it. The lock sits beside the CLI's fixed state
|
|
129
|
+
-- authority. Neovim's configurable `state_path` only changes which file the UI
|
|
130
|
+
-- reads; `jcf timer status` still mutates `~/.jcf/state.json`, so every editor
|
|
131
|
+
-- must coordinate on that one path regardless of its display configuration.
|
|
132
|
+
local function cli_state_path()
|
|
133
|
+
return vim.fn.expand("~/.jcf/state.json")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
local function lock_path()
|
|
137
|
+
return vim.fn.fnamemodify(cli_state_path(), ":h") .. "/poll.lock"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
-- ---------------------------------------------------------------------------
|
|
141
|
+
-- Poll stamp: the lock bounds overlap, this bounds rate.
|
|
142
|
+
-- ---------------------------------------------------------------------------
|
|
143
|
+
--
|
|
144
|
+
-- The lock is held only while `jcf timer status` runs — a second or two — and
|
|
145
|
+
-- released the moment it exits. That makes two polls never overlap, which is not
|
|
146
|
+
-- the same as making them rare: 19 editors on a 30s timer tick every ~1.6s
|
|
147
|
+
-- between them, and by then the lock is free again, so nearly every tick would
|
|
148
|
+
-- still spawn. The lock alone therefore delivers ~19 polls per interval, the
|
|
149
|
+
-- number it exists to avoid.
|
|
150
|
+
--
|
|
151
|
+
-- What actually bounds the machine is a record that survives the release. Each
|
|
152
|
+
-- finished attempt writes a stamp file before releasing the lock, and a tick
|
|
153
|
+
-- that finds the stamp younger than one interval skips. Failed attempts count:
|
|
154
|
+
-- otherwise every de-phased editor would retry the same persistent failure in
|
|
155
|
+
-- turn. Readers keep the last state file until the next attempt. That is the
|
|
156
|
+
-- whole rate limit, and it is what lets the editors stay de-phased without
|
|
157
|
+
-- multiplying the work.
|
|
158
|
+
--
|
|
159
|
+
-- The CLI owns the write so no successor can acquire the lock between process
|
|
160
|
+
-- exit and the stamp becoming visible.
|
|
161
|
+
local function stamp_path()
|
|
162
|
+
return vim.fn.fnamemodify(cli_state_path(), ":h") .. "/poll.stamp"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
local function wall_clock_ms()
|
|
166
|
+
local sec, usec = uv.gettimeofday()
|
|
167
|
+
return (sec * 1000) + math.floor(usec / 1000)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
-- A watchdog escalation bypasses the CLI's Effect finalizers. Write the failed
|
|
171
|
+
-- attempt synchronously while the child still owns `poll.lock`, so another
|
|
172
|
+
-- editor cannot acquire the lock between SIGKILL and the shared rate bound.
|
|
173
|
+
local function stamp_failed_attempt()
|
|
174
|
+
local fd = uv.fs_open(stamp_path(), "w", 384) -- 0600
|
|
175
|
+
if not fd then
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
uv.fs_write(fd, tostring(wall_clock_ms()), -1)
|
|
179
|
+
uv.fs_close(fd)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
-- Whether some editor on this machine already reconciled within the interval.
|
|
183
|
+
-- Read from the stamp's own mtime, so a torn or truncated write still carries a
|
|
184
|
+
-- usable time and no parsing can fail here.
|
|
185
|
+
local function polled_recently()
|
|
186
|
+
if not poll_interval_ms then
|
|
187
|
+
return false
|
|
188
|
+
end
|
|
189
|
+
local stat = uv.fs_stat(stamp_path())
|
|
190
|
+
if not stat then
|
|
191
|
+
return false
|
|
192
|
+
end
|
|
193
|
+
local stamped_at_ms = (stat.mtime.sec * 1000) + math.floor((stat.mtime.nsec or 0) / 1000000)
|
|
194
|
+
local age_ms = wall_clock_ms() - stamped_at_ms
|
|
195
|
+
-- A stamp dated in the future is a clock that moved backwards, not a poll that
|
|
196
|
+
-- has not happened yet; treating it as recent would wedge polling until the
|
|
197
|
+
-- clock caught up.
|
|
198
|
+
if age_ms < 0 then
|
|
199
|
+
return false
|
|
200
|
+
end
|
|
201
|
+
return age_ms < poll_interval_ms
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
local function own_pid()
|
|
205
|
+
if uv.os_getpid then
|
|
206
|
+
return uv.os_getpid()
|
|
207
|
+
end
|
|
208
|
+
return vim.fn.getpid()
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
-- One-shot scheduling keeps this editor's cadence anchored to completion, not
|
|
212
|
+
-- start. A repeating timer would tick one interval after start, see the fresh
|
|
213
|
+
-- completion stamp, skip, and wait a second whole interval before trying again.
|
|
214
|
+
arm_poll = function(delay_ms)
|
|
215
|
+
if not poll_timer or not poll_config then
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
poll_timer:stop()
|
|
219
|
+
poll_timer:start(delay_ms, 0, vim.schedule_wrap(function()
|
|
220
|
+
poll_once(poll_config)
|
|
221
|
+
end))
|
|
222
|
+
end
|
|
223
|
+
|
|
91
224
|
-- Ask `job` to stop, then escalate if it does not. Signals the pid directly
|
|
92
225
|
-- rather than calling `jobstop`, which starts nvim's own ~2s kill timer and
|
|
93
226
|
-- would SIGKILL the CLI long before an in-flight OAuth rotation could finish.
|
|
94
227
|
--
|
|
95
|
-
--
|
|
96
|
-
--
|
|
97
|
-
--
|
|
98
|
-
-- cost of no further polling until nvim restarts.
|
|
228
|
+
-- `flock --no-fork` execs the CLI, so the pid we signal is also the process
|
|
229
|
+
-- holding the kernel lock. The lock cannot be released before that process is
|
|
230
|
+
-- dead.
|
|
99
231
|
local function terminate(job)
|
|
100
232
|
local signalled, pid = pcall(vim.fn.jobpid, job)
|
|
101
233
|
if signalled and pid > 0 then
|
|
102
234
|
uv.kill(pid, "sigterm")
|
|
103
235
|
else
|
|
236
|
+
stamp_failed_attempt()
|
|
104
237
|
vim.fn.jobstop(job) -- no pid to signal; fall back to nvim's own teardown
|
|
105
238
|
end
|
|
106
239
|
|
|
@@ -110,47 +243,73 @@ local function terminate(job)
|
|
|
110
243
|
end
|
|
111
244
|
if vim.fn.jobwait({ job }, 0)[1] ~= -1 then
|
|
112
245
|
poll_job = nil -- already exited without `on_exit` reaching us
|
|
246
|
+
arm_poll(poll_interval_ms)
|
|
113
247
|
return
|
|
114
248
|
end
|
|
115
249
|
-- SIGTERM was not enough. Escalate, and let `on_exit` do the releasing.
|
|
116
250
|
local ok, live_pid = pcall(vim.fn.jobpid, job)
|
|
117
251
|
if ok and live_pid > 0 then
|
|
252
|
+
stamp_failed_attempt()
|
|
118
253
|
uv.kill(live_pid, "sigkill")
|
|
119
254
|
return
|
|
120
255
|
end
|
|
121
256
|
-- No pid to escalate against, so nothing further will make this job report.
|
|
122
257
|
-- `jobstop` once more, then release on the next proof rather than wedging
|
|
123
258
|
-- the poll for the rest of the session.
|
|
259
|
+
stamp_failed_attempt()
|
|
124
260
|
vim.fn.jobstop(job)
|
|
125
261
|
vim.defer_fn(function()
|
|
126
262
|
if poll_job == job and vim.fn.jobwait({ job }, 0)[1] ~= -1 then
|
|
127
263
|
poll_job = nil
|
|
264
|
+
arm_poll(poll_interval_ms)
|
|
128
265
|
end
|
|
129
266
|
end, POLL_STOP_GRACE_MS)
|
|
130
267
|
end, POLL_STOP_GRACE_MS)
|
|
131
268
|
end
|
|
132
269
|
|
|
133
|
-
|
|
270
|
+
poll_once = function(config)
|
|
134
271
|
if poll_job then
|
|
272
|
+
arm_poll(poll_interval_ms)
|
|
135
273
|
return -- previous poll still in flight; do not stack
|
|
136
274
|
end
|
|
137
|
-
if not M.read(
|
|
275
|
+
if not M.read(cli_state_path()).active then
|
|
276
|
+
arm_poll(poll_interval_ms)
|
|
138
277
|
return -- no local timer, nothing to reconcile
|
|
139
278
|
end
|
|
279
|
+
if polled_recently() then
|
|
280
|
+
arm_poll(poll_interval_ms)
|
|
281
|
+
return -- someone already reconciled this interval; read their result instead
|
|
282
|
+
end
|
|
140
283
|
|
|
141
284
|
-- `job` is declared first so on_exit closes over it, not a global.
|
|
142
285
|
local job
|
|
143
|
-
job = vim.fn.jobstart({
|
|
286
|
+
job = vim.fn.jobstart({
|
|
287
|
+
"flock",
|
|
288
|
+
"--no-fork",
|
|
289
|
+
"--nonblock",
|
|
290
|
+
"--conflict-exit-code",
|
|
291
|
+
"75",
|
|
292
|
+
lock_path(),
|
|
293
|
+
config.binary or "jcf",
|
|
294
|
+
"timer",
|
|
295
|
+
"status",
|
|
296
|
+
"--nvim-poll-stamp",
|
|
297
|
+
stamp_path(),
|
|
298
|
+
"--nvim-poll-interval-ms",
|
|
299
|
+
tostring(poll_interval_ms),
|
|
300
|
+
}, {
|
|
144
301
|
on_stdout = function() end,
|
|
145
302
|
on_stderr = function() end,
|
|
146
303
|
on_exit = function()
|
|
147
304
|
if poll_job == job then
|
|
148
305
|
poll_job = nil
|
|
306
|
+
arm_poll(poll_interval_ms)
|
|
149
307
|
end
|
|
150
308
|
end,
|
|
151
309
|
})
|
|
152
310
|
if job <= 0 then
|
|
153
|
-
|
|
311
|
+
arm_poll(poll_interval_ms)
|
|
312
|
+
return -- failed to spawn (`flock` missing); try again next tick
|
|
154
313
|
end
|
|
155
314
|
poll_job = job
|
|
156
315
|
|
|
@@ -167,11 +326,26 @@ function M.start_poll(config, interval_ms)
|
|
|
167
326
|
return
|
|
168
327
|
end
|
|
169
328
|
interval_ms = interval_ms or 30000 -- 30s default
|
|
329
|
+
if type(interval_ms) ~= "number" or interval_ms <= 0 or interval_ms % 1 ~= 0 then
|
|
330
|
+
return -- non-positive and non-integral values disable polling
|
|
331
|
+
end
|
|
332
|
+
poll_config = config
|
|
333
|
+
poll_interval_ms = interval_ms
|
|
334
|
+
|
|
335
|
+
-- Spread the first tick across a whole interval, keyed off the pid, so editors
|
|
336
|
+
-- opened in a batch (a session restore, a fleet of worktrees) do not line up on
|
|
337
|
+
-- the same millisecond forever after. Derived from the pid rather than
|
|
338
|
+
-- `math.random`, which is unseeded per process and would hand every editor the
|
|
339
|
+
-- same offset.
|
|
340
|
+
--
|
|
341
|
+
-- De-phasing is only safe because the stamp bounds the rate. Spreading the
|
|
342
|
+
-- ticks removes the collisions the lock would otherwise resolve, so with the
|
|
343
|
+
-- lock alone this would make things worse, not better: every tick would find
|
|
344
|
+
-- a free lock and spawn.
|
|
345
|
+
local first_ms = own_pid() % interval_ms
|
|
170
346
|
|
|
171
347
|
poll_timer = uv.new_timer()
|
|
172
|
-
|
|
173
|
-
poll_once(config)
|
|
174
|
-
end))
|
|
348
|
+
arm_poll(first_ms)
|
|
175
349
|
end
|
|
176
350
|
|
|
177
351
|
function M.stop_poll()
|
|
@@ -180,6 +354,9 @@ function M.stop_poll()
|
|
|
180
354
|
poll_timer:close()
|
|
181
355
|
poll_timer = nil
|
|
182
356
|
end
|
|
357
|
+
if not poll_job then
|
|
358
|
+
return
|
|
359
|
+
end
|
|
183
360
|
if poll_job then
|
|
184
361
|
-- Exactly the watchdog's sequence, for the same reason: `jobstop` here
|
|
185
362
|
-- would hand the CLI nvim's ~2s kill timer and could SIGKILL it between the
|
|
@@ -187,6 +364,7 @@ function M.stop_poll()
|
|
|
187
364
|
-- reconfiguration deserve the same grace as a timeout. (On `VimLeave` nvim
|
|
188
365
|
-- exits before any of it elapses — the CLI's own retry-not-delete rule is
|
|
189
366
|
-- what covers that case.)
|
|
367
|
+
stamp_failed_attempt()
|
|
190
368
|
terminate(poll_job)
|
|
191
369
|
end
|
|
192
370
|
end
|