workspaces 0.1.20 → 0.1.21
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.
- checksums.yaml +4 -4
- data/README.md +20 -0
- data/lib/workspaces/cli.rb +66 -10
- data/lib/workspaces/config.rb +14 -0
- data/lib/workspaces/coordinator.rb +10 -16
- data/lib/workspaces/front_door.rb +12 -2
- data/lib/workspaces/registry.rb +13 -11
- data/lib/workspaces/version.rb +1 -1
- data/lib/workspaces/workspace_lock.rb +137 -0
- data/lib/workspaces.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2aca45b37e744578006f9b80c3b42a8d664d3b989e9443c10505e357b739338d
|
|
4
|
+
data.tar.gz: cdfb7b28c2fb5f66ace3e778cceb9654a07271fbad73321768829a498248feb5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 764c88c9ebb620cace6381b7e95b1ba32e2cb6e4e80da503a28f68ec1acb783a84a99bf4eb7213cb97e2d4787ce9117bee0c09504649367517b56bb593821a1d
|
|
7
|
+
data.tar.gz: dab1db619647ee35bdcd731c6072b762e9788a42877eb684411368d1e7461230267adedbf8a875354f9d0ec50f91f3a81e4654b75f6c29e14c6b0a0f7d0b8896
|
data/README.md
CHANGED
|
@@ -75,6 +75,26 @@ A few things worth knowing:
|
|
|
75
75
|
`workspaces exec <id> -- git lfs pull --include='path/**'`.
|
|
76
76
|
- Workspaces created before this behavior keep their real files; nothing is converted.
|
|
77
77
|
|
|
78
|
+
### Running a command in a workspace
|
|
79
|
+
|
|
80
|
+
`workspaces exec <id> [--timeout SECONDS] -- COMMAND [ARGUMENTS...]` runs a command in the
|
|
81
|
+
workspace's directory with the workspace's environment, and exits with the command's own status.
|
|
82
|
+
|
|
83
|
+
- It holds the workspace's lock while it runs, so a restart, stop or removal is refused until it
|
|
84
|
+
finishes. That is what stops a command racing a rebuild.
|
|
85
|
+
- A command is stopped after **15 minutes** by default, along with everything it started, and
|
|
86
|
+
`exec` exits 124 (what `timeout(1)` uses). Change it with `--timeout SECONDS` or
|
|
87
|
+
`WORKSPACES_EXEC_TIMEOUT_SECONDS`; `0` means no limit. An explicit `--timeout` wins.
|
|
88
|
+
- Anyone refused while it runs is told what holds the lock, for example
|
|
89
|
+
`Workspace is busy: running a command (bin/rails), pid 1365484, for 28m 5s. It will be stopped
|
|
90
|
+
automatically after 15m 0s`. Only the executable is recorded, never its arguments, because
|
|
91
|
+
those can carry secrets.
|
|
92
|
+
- The lock is released by the operating system when its holder exits for any reason, including
|
|
93
|
+
being killed, so a dead command cannot leave a workspace stuck.
|
|
94
|
+
- The preview keeps being served while a command runs, because `exec` leaves the server alone. A
|
|
95
|
+
restart, rebuild, PR update or removal holding the lock still takes the preview offline, since
|
|
96
|
+
those replace the server or its files.
|
|
97
|
+
|
|
78
98
|
### Step timings
|
|
79
99
|
|
|
80
100
|
Per-step durations are persisted in seconds in workspace step status JSON, so they
|
data/lib/workspaces/cli.rb
CHANGED
|
@@ -225,19 +225,75 @@ module Workspaces
|
|
|
225
225
|
raise ArgumentError, "Could not remove: #{result['problems'].join('; ')}" unless result['problems'].empty?
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
+
EXEC_USAGE = 'Usage: workspaces exec ID [--timeout SECONDS] -- COMMAND [ARGUMENTS...]'.freeze
|
|
229
|
+
# What `timeout(1)` exits with, so scripts that already handle that keep working.
|
|
230
|
+
EXEC_TIMED_OUT = 124
|
|
231
|
+
|
|
228
232
|
def execute(argv)
|
|
229
233
|
id = argv.shift
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
timeout = nil
|
|
235
|
+
while argv.first && argv.first != '--'
|
|
236
|
+
flag = argv.shift
|
|
237
|
+
raise ArgumentError, EXEC_USAGE unless flag == '--timeout' && !argv.empty?
|
|
238
|
+
|
|
239
|
+
timeout = argv.shift
|
|
240
|
+
end
|
|
241
|
+
raise ArgumentError, EXEC_USAGE unless argv.shift == '--' && !argv.empty?
|
|
242
|
+
|
|
243
|
+
limit = Config.exec_timeout_seconds(timeout)
|
|
244
|
+
# Only the executable goes in the record: arguments can carry secrets, and the record is readable
|
|
245
|
+
# by anyone who can read the lock directory.
|
|
246
|
+
registry.with_lock(id, operation: 'exec', detail: File.basename(argv.first)) do |workspace, lock|
|
|
247
|
+
run_exec(workspace, argv, limit, lock)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Runs the command in its own process group so the limit, or a cancel, can stop it and everything it
|
|
252
|
+
# started, then reports what `timeout(1)` would.
|
|
253
|
+
def run_exec(workspace, argv, limit, lock)
|
|
254
|
+
child_env = ChildEnv.for_workspace(workspace_path: workspace.path)
|
|
255
|
+
pid = Process.spawn(child_env, [argv.first, argv.first], *argv.drop(1),
|
|
256
|
+
chdir: workspace.path.to_s, unsetenv_others: true, pgroup: true)
|
|
257
|
+
lock.update('child_pid' => pid, 'process_group' => pid, 'timeout_seconds' => limit)
|
|
258
|
+
status = wait_for_exec(pid, limit)
|
|
259
|
+
return status.exitstatus || (128 + status.termsig) if status
|
|
260
|
+
|
|
261
|
+
warn "workspaces exec: #{File.basename(argv.first)} timed out after #{DurationFormat.call(limit)} and was stopped"
|
|
262
|
+
EXEC_TIMED_OUT
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The exit status, or nil when the limit ran out first and the process group was stopped.
|
|
266
|
+
def wait_for_exec(pid, limit)
|
|
267
|
+
deadline = limit && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + limit)
|
|
268
|
+
loop do
|
|
269
|
+
reaped, status = Process.wait2(pid, Process::WNOHANG)
|
|
270
|
+
return status if reaped
|
|
271
|
+
|
|
272
|
+
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
273
|
+
stop_group(pid)
|
|
274
|
+
return nil
|
|
275
|
+
end
|
|
276
|
+
sleep 0.05
|
|
233
277
|
end
|
|
278
|
+
end
|
|
234
279
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
280
|
+
# TERM first so a command can clean up, KILL for anything still there after a few seconds.
|
|
281
|
+
def stop_group(pid)
|
|
282
|
+
Process.kill('TERM', -pid)
|
|
283
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
284
|
+
until Process.wait2(pid, Process::WNOHANG)
|
|
285
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
286
|
+
|
|
287
|
+
sleep 0.05
|
|
288
|
+
end
|
|
289
|
+
Process.kill('KILL', -pid)
|
|
290
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
291
|
+
nil
|
|
292
|
+
ensure
|
|
293
|
+
begin
|
|
294
|
+
Process.wait2(pid)
|
|
295
|
+
rescue Errno::ECHILD
|
|
296
|
+
nil
|
|
241
297
|
end
|
|
242
298
|
end
|
|
243
299
|
|
|
@@ -257,7 +313,7 @@ module Workspaces
|
|
|
257
313
|
'Usage: bin/workspaces serve [--require FILE] | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
258
314
|
'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
259
315
|
'resume ID [--from STEP | --only STEP] [--json] | stop ID | keep-awake ID [--off] | ' \
|
|
260
|
-
'exec ID -- COMMAND... | remove ID [--force] | purge-secrets [--dry-run]'
|
|
316
|
+
'exec ID [--timeout SECONDS] -- COMMAND... | remove ID [--force] | purge-secrets [--dry-run]'
|
|
261
317
|
end
|
|
262
318
|
|
|
263
319
|
def help(*)
|
data/lib/workspaces/config.rb
CHANGED
|
@@ -20,6 +20,7 @@ module Workspaces
|
|
|
20
20
|
DEFAULT_SLEEP_AFTER_MINUTES = 30
|
|
21
21
|
DEFAULT_WAKE_TIMEOUT_SECONDS = 90
|
|
22
22
|
DEFAULT_SLEEP_SWEEP_SECONDS = 30
|
|
23
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS = 900
|
|
23
24
|
|
|
24
25
|
# Seconds a ready workspace may go without preview traffic before it is put to sleep, or nil when
|
|
25
26
|
# sleeping is switched off (WORKSPACES_SLEEP_AFTER_MINUTES=0). Fractions of a minute are allowed.
|
|
@@ -28,6 +29,19 @@ module Workspaces
|
|
|
28
29
|
minutes.zero? ? nil : minutes * 60
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
# How long `workspaces exec` lets a command run before stopping it, or nil for no limit. An explicit
|
|
33
|
+
# value (the --timeout option) wins over WORKSPACES_EXEC_TIMEOUT_SECONDS, which wins over the default.
|
|
34
|
+
# Zero means no limit.
|
|
35
|
+
def self.exec_timeout_seconds(explicit)
|
|
36
|
+
value = explicit.nil? ? ENV.fetch('WORKSPACES_EXEC_TIMEOUT_SECONDS', DEFAULT_EXEC_TIMEOUT_SECONDS.to_s) : explicit
|
|
37
|
+
seconds = Float(value)
|
|
38
|
+
raise ArgumentError, 'The exec timeout must be a non-negative number of seconds' unless seconds.finite? && seconds >= 0
|
|
39
|
+
|
|
40
|
+
seconds.zero? ? nil : (seconds % 1).zero? ? seconds.to_i : seconds
|
|
41
|
+
rescue ArgumentError, TypeError
|
|
42
|
+
raise ArgumentError, 'The exec timeout must be a non-negative number of seconds'
|
|
43
|
+
end
|
|
44
|
+
|
|
31
45
|
# How long the first request to a sleeping preview waits for the server to be ready.
|
|
32
46
|
def self.wake_timeout_seconds
|
|
33
47
|
positive_number('WORKSPACES_WAKE_TIMEOUT_SECONDS', DEFAULT_WAKE_TIMEOUT_SECONDS)
|
|
@@ -42,7 +42,7 @@ module Workspaces
|
|
|
42
42
|
# message) when the request cannot be honoured.
|
|
43
43
|
def rerun(id, mode: :retry, key: nil)
|
|
44
44
|
workspace = @registry.find(id)
|
|
45
|
-
lock = acquire_lock(id)
|
|
45
|
+
lock = acquire_lock(id, 'resume')
|
|
46
46
|
return false unless lock
|
|
47
47
|
|
|
48
48
|
plan = rerun_plan(id, mode, key)
|
|
@@ -96,7 +96,7 @@ module Workspaces
|
|
|
96
96
|
# the workspace is busy, is kept awake, is not running, or is already asleep.
|
|
97
97
|
def sleep!(id)
|
|
98
98
|
@registry.find(id)
|
|
99
|
-
lock = acquire_lock(id)
|
|
99
|
+
lock = acquire_lock(id, 'sleep')
|
|
100
100
|
return false unless lock
|
|
101
101
|
|
|
102
102
|
begin
|
|
@@ -122,7 +122,7 @@ module Workspaces
|
|
|
122
122
|
# the one holding the workspace lock gets past the check.
|
|
123
123
|
def wake(id)
|
|
124
124
|
workspace = @registry.find(id)
|
|
125
|
-
lock = acquire_lock(id)
|
|
125
|
+
lock = acquire_lock(id, 'wake')
|
|
126
126
|
return false unless lock
|
|
127
127
|
|
|
128
128
|
entry = @state.get(id) || {}
|
|
@@ -142,7 +142,7 @@ module Workspaces
|
|
|
142
142
|
|
|
143
143
|
# Stops the server by hand. A workspace stopped this way is not asleep, so traffic never wakes it.
|
|
144
144
|
def stop(id)
|
|
145
|
-
@registry.with_lock(id) do
|
|
145
|
+
@registry.with_lock(id, operation: 'stop') do
|
|
146
146
|
Backend.new(id).stop!
|
|
147
147
|
@state.set(id, 'status' => 'stopped', 'message' => 'Server stopped', 'sleep_state' => nil, 'slept_at' => nil)
|
|
148
148
|
end
|
|
@@ -249,7 +249,7 @@ module Workspaces
|
|
|
249
249
|
end
|
|
250
250
|
|
|
251
251
|
def finish_interrupted_sleep(id)
|
|
252
|
-
lock = acquire_lock(id)
|
|
252
|
+
lock = acquire_lock(id, 'sleep')
|
|
253
253
|
return unless lock
|
|
254
254
|
|
|
255
255
|
begin
|
|
@@ -262,6 +262,7 @@ module Workspaces
|
|
|
262
262
|
def snapshot_fields(id, entry, busy, status, log, include_log)
|
|
263
263
|
{
|
|
264
264
|
'workspace_id' => id, 'status' => status, 'active' => busy,
|
|
265
|
+
'lock_operation' => busy ? WorkspaceLock.holder(id)&.fetch('operation', nil) : nil,
|
|
265
266
|
'sleeping' => status == 'sleeping', 'slept_at' => entry['slept_at'],
|
|
266
267
|
'keep_awake' => !!entry['keep_awake'], 'sleep_after_seconds' => Config.sleep_after_seconds,
|
|
267
268
|
'message' => status == entry['status'] ? entry['message'] : status.capitalize,
|
|
@@ -290,7 +291,7 @@ module Workspaces
|
|
|
290
291
|
|
|
291
292
|
def launch(id, restart:, force:, prelaunch: nil)
|
|
292
293
|
workspace = @registry.find(id)
|
|
293
|
-
lock = acquire_lock(id)
|
|
294
|
+
lock = acquire_lock(id, restart ? 'restart' : 'prepare')
|
|
294
295
|
return false unless lock
|
|
295
296
|
|
|
296
297
|
if prelaunch && !restart && !force && Backend.new(id).running?
|
|
@@ -335,12 +336,8 @@ module Workspaces
|
|
|
335
336
|
'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil, 'total_seconds' => nil)
|
|
336
337
|
end
|
|
337
338
|
|
|
338
|
-
def acquire_lock(id)
|
|
339
|
-
|
|
340
|
-
# The worker owns this duplicate; closing the request's descriptor must not release its lock.
|
|
341
|
-
File.open(Config.prepare_lock_path(id), File::RDWR | File::CREAT, 0o600) do |file|
|
|
342
|
-
file.dup if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
343
|
-
end
|
|
339
|
+
def acquire_lock(id, operation)
|
|
340
|
+
WorkspaceLock.acquire(id, operation: operation)
|
|
344
341
|
end
|
|
345
342
|
|
|
346
343
|
def publish_starting(id, restart, extra: {})
|
|
@@ -434,10 +431,7 @@ module Workspaces
|
|
|
434
431
|
end
|
|
435
432
|
|
|
436
433
|
def locked?(id)
|
|
437
|
-
|
|
438
|
-
return false unless path.file?
|
|
439
|
-
|
|
440
|
-
File.open(path, File::RDWR) { |lock| !lock.flock(File::LOCK_EX | File::LOCK_NB) }
|
|
434
|
+
WorkspaceLock.held?(id)
|
|
441
435
|
end
|
|
442
436
|
|
|
443
437
|
def log_tail(id, log)
|
|
@@ -165,7 +165,7 @@ module Workspaces
|
|
|
165
165
|
else
|
|
166
166
|
@coordinator.update_environment(id, **parse_environment_changes(params))
|
|
167
167
|
end
|
|
168
|
-
return response(409,
|
|
168
|
+
return response(409, WorkspaceLock.busy_message(id)) unless updated
|
|
169
169
|
|
|
170
170
|
redirect(Config.dashboard_url(id))
|
|
171
171
|
rescue EnvironmentOverrides::Invalid => e
|
|
@@ -326,13 +326,23 @@ module Workspaces
|
|
|
326
326
|
end
|
|
327
327
|
|
|
328
328
|
snapshot = @coordinator.snapshot(id, include_log: false)
|
|
329
|
-
return unavailable(id, request) unless snapshot
|
|
329
|
+
return unavailable(id, request) unless servable?(snapshot)
|
|
330
330
|
|
|
331
331
|
request.env[WORKSPACE_ID_ENV] = id
|
|
332
332
|
request.env[WORKSPACE_ENV] = snapshot
|
|
333
333
|
@forwarder.call(request.env)
|
|
334
334
|
end
|
|
335
335
|
|
|
336
|
+
# A ready workspace is served unless something is changing it. A command run with `exec` leaves the
|
|
337
|
+
# server alone, so a preview keeps working while one runs, and a command that hangs cannot take the
|
|
338
|
+
# preview down with it. Anything else holding the lock (a restart, a rebuild, a PR update, removal) may
|
|
339
|
+
# be replacing the server or its files, and so does an unknown holder: those are still refused.
|
|
340
|
+
def servable?(snapshot)
|
|
341
|
+
return false unless snapshot['status'] == 'ready'
|
|
342
|
+
|
|
343
|
+
!snapshot['active'] || snapshot['lock_operation'] == 'exec'
|
|
344
|
+
end
|
|
345
|
+
|
|
336
346
|
# A lease keeps the workspace awake for as long as its request is being served. A response body
|
|
337
347
|
# that is still being produced (anything but a buffered Array) keeps it until the server closes it.
|
|
338
348
|
def hold_lease(result, lease)
|
data/lib/workspaces/registry.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative 'worktree'
|
|
2
|
+
require_relative 'workspace_lock'
|
|
2
3
|
require_relative 'state_store'
|
|
3
4
|
require_relative 'backend'
|
|
4
5
|
require_relative 'recipe'
|
|
@@ -28,23 +29,24 @@ module Workspaces
|
|
|
28
29
|
workspace
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
# Runs the block holding the workspace's lock, or raises ArgumentError saying who holds it. The block
|
|
33
|
+
# gets the workspace, and the lock when it wants to add to the holder record.
|
|
34
|
+
def with_lock(id, operation: 'busy', detail: nil)
|
|
32
35
|
workspace = find(id)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
yield workspace
|
|
36
|
+
lock = WorkspaceLock.acquire(id, operation: operation, detail: detail)
|
|
37
|
+
raise ArgumentError, WorkspaceLock.busy_message(id) unless lock
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
yield workspace, lock
|
|
41
|
+
ensure
|
|
42
|
+
lock.close
|
|
41
43
|
end
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# Removes the workspace and its saved settings and secrets. Returns the secrets that could not be
|
|
45
47
|
# removed (empty when all went), so callers can say so rather than report a clean removal.
|
|
46
48
|
def remove(id, force: false)
|
|
47
|
-
with_lock(id) do |workspace|
|
|
49
|
+
with_lock(id, operation: 'remove') do |workspace|
|
|
48
50
|
raise ArgumentError, 'Workspace has uncommitted/untracked work' if !force && workspace.dirty?
|
|
49
51
|
|
|
50
52
|
Backend.new(id).stop!
|
|
@@ -78,7 +80,7 @@ module Workspaces
|
|
|
78
80
|
end
|
|
79
81
|
|
|
80
82
|
def update_pr(id)
|
|
81
|
-
with_lock(id) do |workspace|
|
|
83
|
+
with_lock(id, operation: 'update-pr') do |workspace|
|
|
82
84
|
result = workspace.update_from_pr!
|
|
83
85
|
message = result['updated'] ? 'PR checkout updated. Rebuild & restart to run the new code.' : 'PR checkout is already current.'
|
|
84
86
|
StateStore.new(Config.state_file).set(id, 'source_update_message' => message,
|
data/lib/workspaces/version.rb
CHANGED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'config'
|
|
6
|
+
require_relative 'duration_format'
|
|
7
|
+
|
|
8
|
+
module Workspaces
|
|
9
|
+
# The per-workspace lock that serializes preparing, restarting, stopping, removing, updating and
|
|
10
|
+
# running commands, plus a record of who holds it.
|
|
11
|
+
#
|
|
12
|
+
# The lock is a non-blocking exclusive flock, so the kernel releases it when the holder exits for
|
|
13
|
+
# any reason, including SIGKILL. A holder that died therefore never leaves the lock stuck; only a
|
|
14
|
+
# holder that is alive and not finishing does. The record in the file is written by the holder and
|
|
15
|
+
# emptied when it lets go. It is only meaningful while the lock is held, so a record left behind by
|
|
16
|
+
# a holder that died is ignored.
|
|
17
|
+
class WorkspaceLock
|
|
18
|
+
GENERIC_BUSY = 'Workspace is busy preparing, restarting, or running a command'
|
|
19
|
+
|
|
20
|
+
OPERATIONS = {
|
|
21
|
+
'prepare' => 'preparing', 'restart' => 'restarting', 'resume' => 're-running setup steps',
|
|
22
|
+
'sleep' => 'putting the workspace to sleep', 'wake' => 'waking the workspace',
|
|
23
|
+
'stop' => 'stopping the server', 'remove' => 'removing the workspace',
|
|
24
|
+
'update-pr' => 'updating the PR checkout'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Takes the lock, or returns nil when someone else holds it.
|
|
28
|
+
def self.acquire(id, operation:, detail: nil)
|
|
29
|
+
Config.locks_dir.mkpath
|
|
30
|
+
file = File.open(Config.prepare_lock_path(id), File::RDWR | File::CREAT, 0o600)
|
|
31
|
+
unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
32
|
+
file.close
|
|
33
|
+
return nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
lock = new(file)
|
|
37
|
+
lock.update('operation' => operation, 'pid' => Process.pid, 'started_at' => Time.now.utc.iso8601,
|
|
38
|
+
'detail' => detail)
|
|
39
|
+
lock
|
|
40
|
+
rescue StandardError
|
|
41
|
+
file&.close unless file&.closed?
|
|
42
|
+
raise
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# True while some process holds the lock.
|
|
46
|
+
def self.held?(id)
|
|
47
|
+
path = Config.prepare_lock_path(id)
|
|
48
|
+
return false unless path.file?
|
|
49
|
+
|
|
50
|
+
File.open(path, File::RDWR) { |file| !file.flock(File::LOCK_EX | File::LOCK_NB) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# What the current holder wrote, or nil when nothing holds the lock or the record cannot be read.
|
|
54
|
+
def self.holder(id)
|
|
55
|
+
return nil unless held?(id)
|
|
56
|
+
|
|
57
|
+
record = JSON.parse(Config.prepare_lock_path(id).read)
|
|
58
|
+
record.is_a?(Hash) ? record : nil
|
|
59
|
+
rescue JSON::ParserError, SystemCallError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.busy_message(id)
|
|
64
|
+
record = holder(id)
|
|
65
|
+
return GENERIC_BUSY unless record
|
|
66
|
+
|
|
67
|
+
"Workspace is busy: #{describe(record)}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.describe(record)
|
|
71
|
+
operation = record['operation'].to_s
|
|
72
|
+
return describe_command(record) if operation == 'exec'
|
|
73
|
+
|
|
74
|
+
OPERATIONS.fetch(operation, operation)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.describe_command(record)
|
|
78
|
+
text = +'running a command'
|
|
79
|
+
text << " (#{record['detail']})" if record['detail']
|
|
80
|
+
pids = [record['child_pid'] && "pid #{record['child_pid']}"].compact
|
|
81
|
+
text << ", #{pids.join(', ')}" unless pids.empty?
|
|
82
|
+
elapsed = elapsed_text(record['started_at'])
|
|
83
|
+
text << ", for #{elapsed}" if elapsed
|
|
84
|
+
limit = DurationFormat.call(record['timeout_seconds'])
|
|
85
|
+
text << ". It will be stopped automatically after #{limit}" if limit
|
|
86
|
+
text
|
|
87
|
+
end
|
|
88
|
+
private_class_method :describe_command
|
|
89
|
+
|
|
90
|
+
def self.elapsed_text(started_at)
|
|
91
|
+
DurationFormat.call(Time.now - Time.iso8601(started_at.to_s))
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
private_class_method :elapsed_text
|
|
96
|
+
|
|
97
|
+
def initialize(file)
|
|
98
|
+
@file = file
|
|
99
|
+
@record = {}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Adds to what the holder has told the world, keeping what it already said.
|
|
103
|
+
def update(fields)
|
|
104
|
+
@record = @record.merge(fields.compact)
|
|
105
|
+
@file.rewind
|
|
106
|
+
@file.truncate(0)
|
|
107
|
+
@file.write(JSON.generate(@record))
|
|
108
|
+
@file.flush
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A descriptor the background worker owns, so the request that took the lock can close its own
|
|
112
|
+
# without releasing it.
|
|
113
|
+
def dup
|
|
114
|
+
self.class.allocate.tap { |copy| copy.send(:adopt, @file.dup, @record) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def close
|
|
118
|
+
return if @file.closed?
|
|
119
|
+
|
|
120
|
+
@file.truncate(0)
|
|
121
|
+
@file.close
|
|
122
|
+
rescue IOError, SystemCallError
|
|
123
|
+
@file.close unless @file.closed?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def closed?
|
|
127
|
+
@file.closed?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def adopt(file, record)
|
|
133
|
+
@file = file
|
|
134
|
+
@record = record
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/workspaces.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: workspaces
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.21
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Workspaces contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -107,6 +107,7 @@ files:
|
|
|
107
107
|
- lib/workspaces/vault/file_vault.rb
|
|
108
108
|
- lib/workspaces/version.rb
|
|
109
109
|
- lib/workspaces/workflow.rb
|
|
110
|
+
- lib/workspaces/workspace_lock.rb
|
|
110
111
|
- lib/workspaces/worktree.rb
|
|
111
112
|
homepage:
|
|
112
113
|
licenses:
|