little_ghost 0.5.0 → 0.7.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/docs/guides/code_mode.md +6 -3
  4. data/docs/guides/integrations.md +4 -4
  5. data/docs/guides/models_and_providers.md +91 -0
  6. data/docs/guides/production.md +114 -7
  7. data/docs/guides/tools.md +26 -0
  8. data/lib/little_ghost/agent.rb +5 -5
  9. data/lib/little_ghost/assembly.rb +9 -11
  10. data/lib/little_ghost/assembly_execution.rb +8 -17
  11. data/lib/little_ghost/code_mode/broker.rb +4 -0
  12. data/lib/little_ghost/code_mode/javascript/session.rb +23 -24
  13. data/lib/little_ghost/code_mode/ruby/host.rb +50 -10
  14. data/lib/little_ghost/code_mode/ruby/session.rb +22 -18
  15. data/lib/little_ghost/code_mode/ruby_engine.rb +1 -0
  16. data/lib/little_ghost/configuration.rb +101 -9
  17. data/lib/little_ghost/embeddings/request.rb +103 -0
  18. data/lib/little_ghost/embeddings/response.rb +55 -0
  19. data/lib/little_ghost/errors.rb +2 -0
  20. data/lib/little_ghost/execution.rb +31 -25
  21. data/lib/little_ghost/execution_state.rb +3 -3
  22. data/lib/little_ghost/graph.rb +6 -3
  23. data/lib/little_ghost/instrumentation.rb +2 -1
  24. data/lib/little_ghost/mcp/toolset.rb +1 -1
  25. data/lib/little_ghost/model.rb +20 -0
  26. data/lib/little_ghost/model_operations.rb +210 -0
  27. data/lib/little_ghost/network/certificate_authority.rb +6 -0
  28. data/lib/little_ghost/network/envoy_gateway.rb +11 -2
  29. data/lib/little_ghost/providers/base.rb +16 -1
  30. data/lib/little_ghost/providers/bedrock/aws_protocol.rb +1 -1
  31. data/lib/little_ghost/providers/bedrock/http_client.rb +28 -7
  32. data/lib/little_ghost/providers/bedrock.rb +93 -8
  33. data/lib/little_ghost/providers/openai.rb +80 -4
  34. data/lib/little_ghost/run.rb +9 -15
  35. data/lib/little_ghost/run_context.rb +2 -1
  36. data/lib/little_ghost/runtime/hooks/artifacts.rb +8 -0
  37. data/lib/little_ghost/runtime.rb +35 -3
  38. data/lib/little_ghost/sandbox/process_runner.rb +1 -1
  39. data/lib/little_ghost/sandboxes/unrestricted.rb +1 -2
  40. data/lib/little_ghost/session_stores/filesystem.rb +29 -13
  41. data/lib/little_ghost/subagents/manager.rb +170 -70
  42. data/lib/little_ghost/support/cancellation_token.rb +2 -2
  43. data/lib/little_ghost/support/executor.rb +84 -15
  44. data/lib/little_ghost/support/http_client.rb +19 -5
  45. data/lib/little_ghost/support/interruptible_stream.rb +4 -2
  46. data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
  47. data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
  48. data/lib/little_ghost/support/task.rb +126 -0
  49. data/lib/little_ghost/support/task_runner.rb +74 -0
  50. data/lib/little_ghost/support.rb +4 -0
  51. data/lib/little_ghost/tracing/open_telemetry.rb +6 -4
  52. data/lib/little_ghost/version.rb +1 -1
  53. data/lib/little_ghost/workflow.rb +3 -3
  54. data/lib/little_ghost.rb +42 -0
  55. metadata +27 -6
@@ -3,6 +3,7 @@
3
3
  require "securerandom"
4
4
  require "base64"
5
5
  require "digest"
6
+ require "monitor"
6
7
  require "time"
7
8
  require_relative "definition"
8
9
  require_relative "control_tool"
@@ -37,7 +38,9 @@ module LittleGhost
37
38
  # With a parent session, durable definitions retain only committed compact
38
39
  # transcripts and limited state snapshots. Failed or cancelled turns never
39
40
  # become committed conversation history. Call #close to cancel and join
40
- # workers owned by a directly constructed manager.
41
+ # workers owned by a directly constructed manager. If cooperative fiber
42
+ # cleanup exceeds the deadline, the manager remains closed to new work and
43
+ # a later #close retries cleanup.
41
44
  class Manager
42
45
  # Raised when one or more managed workers cannot stop within the cleanup
43
46
  # deadline.
@@ -100,7 +103,6 @@ module LittleGhost
100
103
  :progress_message,
101
104
  :progress_sequence
102
105
  )
103
-
104
106
  class Completion # :nodoc:
105
107
  def initialize
106
108
  @mutex = Mutex.new
@@ -248,12 +250,17 @@ module LittleGhost
248
250
  @cancellation_token = cancellation_token.child
249
251
  @deadline = deadline
250
252
  @observer = observer
253
+ @observer_events = []
254
+ @observer_flushing = false
255
+ @task_runner = runtime ? runtime.task_runner : Support::TaskRunner.new
251
256
  @parent_session = parent_session
252
257
  @parent_agent_path = AgentPath.validate!(parent_agent_path)
253
258
  @parent_link = parent_session && self.class.parent_link(parent_session)
254
259
  @registry_session = parent_session && registry_session
255
260
  @capacity = Capacity.new(max_concurrent)
256
261
  @mutex = Mutex.new
262
+ @close_monitor = Monitor.new
263
+ @close_active = false
257
264
  @registry_mutex = Mutex.new
258
265
  @restore_mutex = Mutex.new
259
266
  @condition = ConditionVariable.new
@@ -262,6 +269,8 @@ module LittleGhost
262
269
  @identity_slots = 0
263
270
  @turn_count = 0
264
271
  @closed = false
272
+ @close_complete = false
273
+ @closed_agents = {}
265
274
  restore_identities
266
275
  end
267
276
 
@@ -330,14 +339,28 @@ module LittleGhost
330
339
  raise Error, "Subagent manager is closed"
331
340
  end
332
341
 
333
- turn, queued_snapshot = enqueue(
334
- identity,
335
- task,
336
- event: "spawned",
337
- count_turn: false,
338
- parent_operation_id:,
339
- context:
340
- )
342
+ begin
343
+ turn, queued_snapshot = enqueue(
344
+ identity,
345
+ task,
346
+ event: "spawned",
347
+ count_turn: false,
348
+ parent_operation_id:,
349
+ context:
350
+ )
351
+ rescue
352
+ synchronize_close do
353
+ should_close = @mutex.synchronize do
354
+ if @identities.delete(subagent_id)
355
+ @identity_slots -= 1
356
+ @turn_count -= 1
357
+ end
358
+ !@closed_agents.key?(agent.object_id)
359
+ end
360
+ agent.close if should_close && agent.respond_to?(:close)
361
+ end
362
+ raise
363
+ end
341
364
  return {status: "working", subagent: queued_snapshot} if mode == "async"
342
365
 
343
366
  turn.completion.value(cancellation_token: @cancellation_token, deadline: @deadline)
@@ -665,61 +688,99 @@ module LittleGhost
665
688
  end
666
689
 
667
690
  # Cancels queued work, cooperatively stops workers, and closes child
668
- # agents. Raises CleanupError if workers do not stop within the bound.
691
+ # agents. Raises CleanupError if workers do not stop within the bound. A
692
+ # later call retries unfinished cleanup without accepting new work.
669
693
  def close
670
- workers = @mutex.synchronize do
671
- return if @closed
672
-
673
- @closed = true
674
- @cancellation_token.cancel
675
- @identities.each_value do |identity|
676
- next if %w[idle failed cancelled persisting].include?(identity.status)
677
-
678
- turn = identity.current
679
- identity.status = "cancelled"
680
- turn&.completion&.resolve(cancelled_turn(identity, turn))
681
- identity.progress_message = nil
682
- identity.current_turn = nil
683
- identity.current = nil
684
- cancel_queued_turns(identity)
685
- emit("cancelled", identity, turn:)
694
+ synchronize_close { perform_close }
695
+ end
696
+
697
+ private
698
+
699
+ def synchronize_close
700
+ @close_monitor.synchronize do
701
+ return if @close_active
702
+
703
+ @close_active = true
704
+ begin
705
+ yield
706
+ ensure
707
+ @close_active = false
686
708
  end
687
- @condition.broadcast
688
- @identities.values.filter_map(&:worker)
689
709
  end
710
+ end
690
711
 
691
- deadline = monotonic_time + @close_timeout
692
- cooperative_deadline = monotonic_time + (@close_timeout / 2.0)
712
+ def perform_close
713
+ started_at = monotonic_time
714
+ deadline = started_at + @close_timeout
715
+ cooperative_deadline = started_at + (@close_timeout / 2.0)
716
+ identities = @mutex.synchronize do
717
+ return if @close_complete
718
+
719
+ unless @closed
720
+ @closed = true
721
+ @cancellation_token.cancel
722
+ @identities.each_value do |identity|
723
+ next if %w[idle failed cancelled persisting].include?(identity.status)
724
+
725
+ turn = identity.current
726
+ identity.status = "cancelled"
727
+ turn&.completion&.resolve(cancelled_turn(identity, turn))
728
+ identity.progress_message = nil
729
+ identity.current_turn = nil
730
+ identity.current = nil
731
+ cancel_queued_turns(identity)
732
+ queue_observer_event("cancelled", identity, turn:)
733
+ end
734
+ @condition.broadcast
735
+ end
736
+
737
+ @identities.values.dup
738
+ end
739
+ flush_observer_events
740
+
741
+ worker_entries = identities.filter_map do |identity|
742
+ worker = identity.worker
743
+ [identity, worker] if worker
744
+ end
745
+ workers = worker_entries.map(&:last)
693
746
  workers.each do |worker|
694
747
  remaining = cooperative_deadline - monotonic_time
695
748
  break unless remaining.positive?
696
749
 
697
- worker.join(remaining)
750
+ wait_for_worker(worker, remaining)
698
751
  end
699
- workers.select(&:alive?).each(&:kill)
752
+ workers.select(&:alive?).each(&:terminate)
700
753
  workers.each do |worker|
701
754
  remaining = deadline - monotonic_time
702
755
  break unless remaining.positive?
703
756
 
704
- worker.join(remaining)
757
+ wait_for_worker(worker, remaining)
705
758
  end
706
759
  first_error = nil
707
- survivors = workers.select(&:alive?)
708
- unless survivors.empty?
760
+ surviving_task_entries = worker_entries.select { |_identity, worker| worker.alive? }
761
+ surviving_identities = surviving_task_entries.map(&:first).uniq(&:object_id)
762
+ unless surviving_identities.empty?
709
763
  first_error ||= CleanupError.new(
710
- "#{survivors.length} subagent worker(s) did not stop within #{@close_timeout} seconds"
764
+ "#{surviving_identities.length} subagent worker(s) did not stop within #{@close_timeout} seconds"
711
765
  )
712
766
  end
713
- agents = @mutex.synchronize { @identities.values.map(&:agent).reverse.uniq(&:object_id) }
767
+
768
+ blocked_identities = surviving_task_entries.map(&:first)
769
+ blocked_agents = blocked_identities.filter_map(&:agent).to_h { |agent| [agent.object_id, true] }
770
+ agents = identities.reverse.filter_map(&:agent).uniq(&:object_id).reject do |agent|
771
+ blocked_agents.key?(agent.object_id) || @mutex.synchronize { @closed_agents.key?(agent.object_id) }
772
+ end
714
773
  agents.each do |agent|
715
774
  agent.close if agent.respond_to?(:close)
775
+ @mutex.synchronize { @closed_agents[agent.object_id] = true }
716
776
  rescue => error
717
777
  first_error ||= error
718
778
  end
719
779
  raise first_error if first_error
720
- end
721
780
 
722
- private
781
+ @mutex.synchronize { @close_complete = true }
782
+ nil
783
+ end
723
784
 
724
785
  def restore_identities
725
786
  return unless @registry_session
@@ -1086,6 +1147,8 @@ module LittleGhost
1086
1147
  return rejection if rejection
1087
1148
  end
1088
1149
 
1150
+ worker = @task_runner.spawn { run_identity(identity) } unless identity.worker&.alive?
1151
+
1089
1152
  turn = Turn.new(
1090
1153
  number: identity.next_turn,
1091
1154
  message: message,
@@ -1100,17 +1163,14 @@ module LittleGhost
1100
1163
  @turn_count += 1 if count_turn
1101
1164
  identity.queue << turn
1102
1165
  identity.status = "queued" unless identity.status == "running"
1103
- emit(event, identity, turn:)
1166
+ identity.worker = worker if worker
1167
+ queue_observer_event(event, identity, turn:)
1104
1168
  queued_snapshot = snapshot(identity)
1105
- unless identity.worker&.alive?
1106
- execution_state = ExecutionState.capture
1107
- identity.worker = Thread.new do
1108
- ExecutionState.with(execution_state) { run_identity(identity) }
1109
- end
1110
- end
1111
1169
  @condition.broadcast
1112
1170
  end
1113
1171
  [turn, queued_snapshot]
1172
+ ensure
1173
+ flush_observer_events
1114
1174
  end
1115
1175
 
1116
1176
  def run_identity(identity)
@@ -1145,11 +1205,12 @@ module LittleGhost
1145
1205
  identity.status = "running"
1146
1206
  identity.progress_message = nil
1147
1207
  identity.progress_sequence += 1
1148
- emit("turn_started", identity, turn:)
1208
+ queue_observer_event("turn_started", identity, turn:)
1149
1209
  @condition.broadcast
1150
1210
  true
1151
1211
  end
1152
1212
  end
1213
+ flush_observer_events
1153
1214
  next unless should_run
1154
1215
 
1155
1216
  begin
@@ -1194,9 +1255,10 @@ module LittleGhost
1194
1255
  end
1195
1256
  ensure
1196
1257
  @mutex.synchronize do
1197
- identity.worker = nil if identity.worker == Thread.current
1258
+ identity.worker = nil if identity.worker&.current?
1198
1259
  @condition.broadcast
1199
1260
  end
1261
+ flush_observer_events
1200
1262
  end
1201
1263
 
1202
1264
  def run_agent_turn(identity, turn, options)
@@ -1221,11 +1283,12 @@ module LittleGhost
1221
1283
  @condition.broadcast
1222
1284
  if tool_use
1223
1285
  event_name = (event.type == :tool_start) ? "tool_started" : "tool_finished"
1224
- emit(event_name, identity, turn:, tool_call_id: tool_use.id, tool_name: tool_use.name)
1286
+ queue_observer_event(event_name, identity, turn:, tool_call_id: tool_use.id, tool_name: tool_use.name)
1225
1287
  else
1226
- emit("activity", identity, turn:)
1288
+ queue_observer_event("activity", identity, turn:)
1227
1289
  end
1228
1290
  end
1291
+ flush_observer_events
1229
1292
  end
1230
1293
 
1231
1294
  def progress_message(event)
@@ -1321,9 +1384,10 @@ module LittleGhost
1321
1384
  }
1322
1385
  value[:response_truncated] = true if truncated
1323
1386
  turn.completion.resolve(value)
1324
- emit("turn_finished", identity, turn:)
1387
+ queue_observer_event("turn_finished", identity, turn:)
1325
1388
  @condition.broadcast
1326
1389
  end
1390
+ flush_observer_events
1327
1391
  end
1328
1392
 
1329
1393
  def retain_agent_conversation(identity, turn, result, persisted_response, interject_exchanges)
@@ -1384,10 +1448,11 @@ module LittleGhost
1384
1448
  error: identity.latest_error
1385
1449
  )
1386
1450
  end
1387
- emit("turn_failed", identity, turn:, error_type: error.class.name)
1451
+ queue_observer_event("turn_failed", identity, turn:, error_type: error.class.name)
1388
1452
  fail_queued_turns(identity)
1389
1453
  @condition.broadcast
1390
1454
  end
1455
+ flush_observer_events
1391
1456
  end
1392
1457
 
1393
1458
  def cancel_unrun_turn(identity, turn)
@@ -1400,9 +1465,10 @@ module LittleGhost
1400
1465
  identity.current = nil
1401
1466
  identity.status = "cancelled"
1402
1467
  cancel_queued_turns(identity)
1403
- emit("cancelled", identity, turn:) if newly_cancelled
1468
+ queue_observer_event("cancelled", identity, turn:) if newly_cancelled
1404
1469
  @condition.broadcast
1405
1470
  end
1471
+ flush_observer_events
1406
1472
  end
1407
1473
 
1408
1474
  def cancelled_turn(identity, turn)
@@ -1418,7 +1484,7 @@ module LittleGhost
1418
1484
  def cancel_queued_turns(identity)
1419
1485
  identity.queue.each do |turn|
1420
1486
  turn.completion.resolve(cancelled_turn(identity, turn))
1421
- emit("cancelled", identity, turn:)
1487
+ queue_observer_event("cancelled", identity, turn:)
1422
1488
  end
1423
1489
  identity.queue.clear
1424
1490
  end
@@ -1432,7 +1498,7 @@ module LittleGhost
1432
1498
  turn: turn.number,
1433
1499
  error: "A previous turn failed; spawn a new identity."
1434
1500
  )
1435
- emit("turn_failed", identity, turn:)
1501
+ queue_observer_event("turn_failed", identity, turn:)
1436
1502
  end
1437
1503
  identity.queue.clear
1438
1504
  end
@@ -1526,7 +1592,7 @@ module LittleGhost
1526
1592
  %w[idle failed cancelled].include?(identity.status)
1527
1593
  end
1528
1594
 
1529
- def emit(event, identity, turn: nil, **attributes)
1595
+ def queue_observer_event(event, identity, turn: nil, **attributes)
1530
1596
  return unless @observer
1531
1597
 
1532
1598
  value = {
@@ -1545,9 +1611,7 @@ module LittleGhost
1545
1611
  end
1546
1612
  end
1547
1613
  value.merge!(attributes)
1548
- @observer.call(value.freeze)
1549
- rescue
1550
- nil
1614
+ @observer_events << value.freeze
1551
1615
  end
1552
1616
 
1553
1617
  def observe_delegated_activity(identity)
@@ -1565,21 +1629,57 @@ module LittleGhost
1565
1629
  @condition.broadcast
1566
1630
  identity.current
1567
1631
  end
1568
- emit("activity", identity, turn:) if turn
1632
+ if turn
1633
+ @mutex.synchronize { queue_observer_event("activity", identity, turn:) }
1634
+ flush_observer_events
1635
+ end
1569
1636
  end
1570
1637
 
1571
1638
  def emit_factory_failure(definition, subagent_id, error, parent_operation_id: nil)
1572
1639
  return unless @observer
1573
1640
 
1574
- @observer.call({
1575
- event: "factory_failed",
1576
- subagent_id:,
1577
- kind: definition.kind,
1578
- status: "failed",
1579
- error_type: error.class.name,
1580
- parent_operation_id:
1581
- }.compact.freeze)
1582
- rescue
1641
+ @mutex.synchronize do
1642
+ @observer_events << {
1643
+ event: "factory_failed",
1644
+ subagent_id:,
1645
+ kind: definition.kind,
1646
+ status: "failed",
1647
+ error_type: error.class.name,
1648
+ parent_operation_id:
1649
+ }.compact.freeze
1650
+ end
1651
+ flush_observer_events
1652
+ end
1653
+
1654
+ def flush_observer_events
1655
+ return unless @observer
1656
+
1657
+ should_flush = @mutex.synchronize do
1658
+ next false if @observer_flushing || @observer_events.empty?
1659
+
1660
+ @observer_flushing = true
1661
+ end
1662
+ return unless should_flush
1663
+
1664
+ loop do
1665
+ value = @mutex.synchronize do
1666
+ event = @observer_events.shift
1667
+ @observer_flushing = false unless event
1668
+ event
1669
+ end
1670
+ break unless value
1671
+
1672
+ begin
1673
+ @observer.call(value)
1674
+ rescue
1675
+ nil
1676
+ end
1677
+ end
1678
+ end
1679
+
1680
+ def wait_for_worker(worker, timeout)
1681
+ worker.wait(deadline: Time.now + timeout)
1682
+ rescue DeadlineExceededError
1583
1683
  nil
1584
1684
  end
1585
1685
 
@@ -3,8 +3,8 @@
3
3
  module LittleGhost
4
4
  module Support
5
5
  # CancellationToken lets related work stop cooperatively without killing its
6
- # calling thread. Child tokens make cancellation flow through a run's tree of
7
- # work.
6
+ # calling thread or fiber. Child tokens make cancellation flow through a
7
+ # run's tree of work.
8
8
  #
9
9
  # Cancellation is idempotent and flows only downward. Long-running
10
10
  # extensions should call #raise_if_cancelled! at bounded intervals.
@@ -6,15 +6,38 @@ module LittleGhost
6
6
  # the final results. It gives framework extensions bounded parallelism without
7
7
  # losing cancellation or request-scoped state.
8
8
  #
9
- # ExecutionState is copied to workers. +on_result+ runs on the calling
10
- # thread in completion order. After all workers join, the first cleanup
11
- # error, or otherwise the first input-order error, is raised.
12
- class Executor
13
- # Sets the maximum number of worker threads.
14
- def initialize(max_concurrency: 8)
9
+ # ExecutionState is copied to workers. +on_result+ runs on the caller in
10
+ # completion order. After all workers join, the first
11
+ # cleanup error, or otherwise the first input-order error, is raised.
12
+ class Executor # :nodoc:
13
+ # Sets the maximum number of batch workers and their execution policy.
14
+ def initialize(max_concurrency: 8, runner: TaskRunner.new, wait_through_interruptions: false)
15
15
  raise ArgumentError, "max_concurrency must be at least 1" if max_concurrency < 1
16
16
 
17
17
  @max_concurrency = max_concurrency
18
+ @runner = runner
19
+ @wait_through_interruptions = wait_through_interruptions
20
+ end
21
+
22
+ attr_reader :runner # :nodoc:
23
+
24
+ # Submits one unit of work and returns its Task.
25
+ def submit(&work)
26
+ @runner.spawn(&work)
27
+ end
28
+
29
+ # Runs one unit of work and returns its result.
30
+ def call(&work)
31
+ resolve(submit(&work))
32
+ end
33
+
34
+ # Runs one unit of work only when runner capacity is immediately
35
+ # available. Returns an accepted flag and the result.
36
+ def try_call(&work)
37
+ task = @runner.try_spawn(&work)
38
+ return [false, nil] unless task
39
+
40
+ [true, resolve(task)]
18
41
  end
19
42
 
20
43
  # Maps +values+ with at most the configured number of workers.
@@ -34,11 +57,10 @@ module LittleGhost
34
57
  error_mutex = Mutex.new
35
58
  first_worker_error = nil
36
59
  worker_count = [@max_concurrency, items.length].min
37
- execution_state = ExecutionState.capture
38
-
39
- workers = worker_count.times.map do
40
- Thread.new do
41
- ExecutionState.with(execution_state) do
60
+ workers = []
61
+ begin
62
+ worker_count.times do
63
+ workers << submit do
42
64
  loop do
43
65
  index = begin
44
66
  queue.pop(true)
@@ -57,15 +79,29 @@ module LittleGhost
57
79
  completions << index
58
80
  end
59
81
  end
82
+ ensure
83
+ completions << :worker_finished
60
84
  end
61
85
  end
86
+ rescue
87
+ cancellation_token.cancel
88
+ workers.each(&:wait)
89
+ raise
62
90
  end
63
91
  callback_error = nil
64
92
  begin
65
- items.length.times do
66
- index = completions.pop
93
+ completed_items = 0
94
+ finished_workers = 0
95
+ until completed_items == items.length || finished_workers == workers.length
96
+ completion = completions.pop
97
+ if completion == :worker_finished
98
+ finished_workers += 1
99
+ next
100
+ end
101
+
102
+ completed_items += 1
67
103
  begin
68
- on_result.call(index, results[index]) if on_result && !errors[index]
104
+ on_result.call(completion, results[completion]) if on_result && !errors[completion]
69
105
  rescue => error
70
106
  callback_error = error
71
107
  cancellation_token.cancel
@@ -73,15 +109,48 @@ module LittleGhost
73
109
  end
74
110
  end
75
111
  ensure
76
- workers.each(&:join)
112
+ workers.each(&:wait)
77
113
  end
78
114
 
79
115
  raise callback_error if callback_error
116
+ task_error = workers.filter_map(&:error).first
117
+ raise task_error if task_error
118
+
80
119
  first_error = errors.compact.find { |error| error.is_a?(CleanupError) } || first_worker_error
81
120
  raise first_error if first_error
82
121
 
83
122
  results
84
123
  end
124
+
125
+ private
126
+
127
+ def resolve(task)
128
+ interruption = nil
129
+ loop do
130
+ task.wait
131
+ break
132
+ rescue Exception => error # rubocop:disable Lint/RescueException
133
+ raise unless @wait_through_interruptions
134
+
135
+ interruption ||= error
136
+ break unless task.alive?
137
+ end
138
+ raise interruption if interruption
139
+ raise task.error if task.error
140
+
141
+ task.result
142
+ end
143
+
144
+ # The process-wide Executor for work moved off a scheduler thread.
145
+ BLOCKING = new(
146
+ runner: PooledThreadRunner.new,
147
+ wait_through_interruptions: true
148
+ ) # :nodoc:
149
+ private_constant :BLOCKING
150
+
151
+ class << self
152
+ def blocking = BLOCKING # :nodoc:
153
+ end
85
154
  end
86
155
  end
87
156
  end
@@ -70,6 +70,7 @@ module LittleGhost
70
70
  method: :post,
71
71
  headers:,
72
72
  body:,
73
+ cancellation_token:,
73
74
  deadline:,
74
75
  allow_insecure_http: @allow_insecure_http,
75
76
  label: "Provider"
@@ -80,7 +81,8 @@ module LittleGhost
80
81
 
81
82
  # Executes a bounded request and returns the complete response body.
82
83
  def request(uri:, method: :get, headers: {}, body: nil, allow_insecure_http: false,
83
- cancellation_token: nil, deadline: nil)
84
+ cancellation_token: nil, deadline: nil, label: "HTTP request", max_response_bytes: @max_response_bytes)
85
+ response_limit = positive_integer(max_response_bytes, :max_response_bytes)
84
86
  response_body = +""
85
87
  each_chunk(
86
88
  uri:,
@@ -89,8 +91,14 @@ module LittleGhost
89
91
  body:,
90
92
  cancellation_token:,
91
93
  deadline: deadline || Time.now + @read_timeout,
92
- allow_insecure_http:
93
- ) { |chunk| response_body << chunk }
94
+ allow_insecure_http:,
95
+ label:
96
+ ) do |chunk|
97
+ if response_body.bytesize + chunk.bytesize > response_limit
98
+ raise ProtocolError, "#{label} response exceeded #{response_limit} bytes"
99
+ end
100
+ response_body << chunk
101
+ end
94
102
  response_body
95
103
  end
96
104
 
@@ -119,7 +127,12 @@ module LittleGhost
119
127
  http.write_timeout = remaining_timeout(deadline, @read_timeout)
120
128
  http.request(request) do |response|
121
129
  unless response.is_a?(Net::HTTPSuccess)
122
- response_body = read_limited(response, @max_error_body_bytes)
130
+ response_body = read_limited(
131
+ response,
132
+ @max_error_body_bytes,
133
+ cancellation_token:,
134
+ deadline:
135
+ )
123
136
  raise Providers::HTTPError.new(
124
137
  "#{label} failed with HTTP #{response.code}",
125
138
  status: response.code.to_i,
@@ -165,9 +178,10 @@ module LittleGhost
165
178
  [remaining, maximum].min
166
179
  end
167
180
 
168
- def read_limited(response, limit)
181
+ def read_limited(response, limit, cancellation_token:, deadline:)
169
182
  body = +""
170
183
  response.read_body do |chunk|
184
+ check_control!(cancellation_token, deadline)
171
185
  remaining = limit - body.bytesize
172
186
  body << chunk.byteslice(0, remaining) if remaining.positive?
173
187
  break if body.bytesize >= limit
@@ -8,7 +8,9 @@ module LittleGhost
8
8
  #
9
9
  # The producer receives an emitter callable. Ending enumeration early stops
10
10
  # and joins the producer; CleanupError is raised if it cannot be stopped
11
- # within the fixed shutdown bound.
11
+ # within the fixed shutdown bound. The producer remains on a worker thread
12
+ # even when the consumer uses a Fiber scheduler, allowing cleanup to
13
+ # interrupt a provider SDK or socket read that remains blocked.
12
14
  #
13
15
  # stream = LittleGhost::Support::InterruptibleStream.new(
14
16
  # cancellation_token: token
@@ -36,7 +38,7 @@ module LittleGhost
36
38
  end
37
39
 
38
40
  # Yields produced values, raising producer, cancellation, deadline, or
39
- # cleanup errors in the consuming thread.
41
+ # cleanup errors in the caller.
40
42
  def each
41
43
  return enum_for(__method__) unless block_given?
42
44