@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.
@@ -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
- -- The guard is never released here: only proof of death releases it, because
96
- -- letting a tick spawn a second poll beside a dying one is the stacking leak
97
- -- this whole mechanism exists to prevent. When in doubt we keep holding, at the
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
- local function poll_once(config)
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(config.state_path).active then
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({ config.binary or "jcf", "timer", "status" }, {
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
- return -- failed to spawn (missing binary); try again next tick
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
- poll_timer:start(interval_ms, interval_ms, vim.schedule_wrap(function()
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/jira-clockify",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "TUI for Jira-Clockify time tracking, attachable to neovim",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",