@temporalio/core-bridge 0.20.1 → 0.21.1

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.
@@ -57,7 +57,7 @@ pub struct WorkflowTaskManager {
57
57
  pending_activations: PendingActivations,
58
58
  /// Holds activations which are purely query activations needed to respond to legacy queries.
59
59
  /// Activations may only be added here for runs which do not have other pending activations.
60
- pending_legacy_queries: SegQueue<WorkflowActivation>,
60
+ pending_queries: SegQueue<WorkflowActivation>,
61
61
  /// Holds poll wft responses from the server that need to be applied
62
62
  ready_buffered_wft: SegQueue<ValidPollWFTQResponse>,
63
63
  /// Used to wake blocked workflow task polling
@@ -74,9 +74,8 @@ pub struct WorkflowTaskManager {
74
74
  #[derive(Clone, Debug)]
75
75
  pub(crate) struct OutstandingTask {
76
76
  pub info: WorkflowTaskInfo,
77
- /// If set the outstanding task has query from the old `query` field which must be fulfilled
78
- /// upon finishing replay
79
- pub legacy_query: Option<QueryWorkflow>,
77
+ /// Set if the outstanding task has quer(ies) which must be fulfilled upon finishing replay
78
+ pub pending_queries: Vec<QueryWorkflow>,
80
79
  start_time: Instant,
81
80
  }
82
81
 
@@ -150,6 +149,13 @@ pub(crate) enum ActivationAction {
150
149
  RespondLegacyQuery { result: QueryResult },
151
150
  }
152
151
 
152
+ #[derive(Debug, Eq, PartialEq, Hash)]
153
+ pub(crate) enum EvictionRequestResult {
154
+ EvictionIssued(Option<u32>),
155
+ NotFound,
156
+ EvictionAlreadyOutstanding,
157
+ }
158
+
153
159
  macro_rules! machine_mut {
154
160
  ($myself:ident, $run_id:ident, $clos:expr) => {{
155
161
  $myself
@@ -172,7 +178,7 @@ impl WorkflowTaskManager {
172
178
  Self {
173
179
  workflow_machines: WorkflowConcurrencyManager::new(),
174
180
  pending_activations: Default::default(),
175
- pending_legacy_queries: Default::default(),
181
+ pending_queries: Default::default(),
176
182
  ready_buffered_wft: Default::default(),
177
183
  pending_activations_notifier,
178
184
  cache_manager: Mutex::new(WorkflowCacheManager::new(eviction_policy, metrics.clone())),
@@ -181,8 +187,8 @@ impl WorkflowTaskManager {
181
187
  }
182
188
 
183
189
  pub(crate) fn next_pending_activation(&self) -> Option<WorkflowActivation> {
184
- // Dispatch pending legacy queries first
185
- if let leg_q @ Some(_) = self.pending_legacy_queries.pop() {
190
+ // Dispatch pending queries first
191
+ if let leg_q @ Some(_) = self.pending_queries.pop() {
186
192
  return leg_q;
187
193
  }
188
194
  // It is important that we do not issue pending activations for any workflows which already
@@ -201,12 +207,18 @@ impl WorkflowTaskManager {
201
207
  if let Some(reason) = pending_info.needs_eviction {
202
208
  act.append_evict_job(reason);
203
209
  }
204
- self.insert_outstanding_activation(&act)?;
205
- Ok(act)
210
+ // If for whatever reason we triggered a pending activation but there wasn't
211
+ // actually any work to be done, just ignore that.
212
+ if !act.jobs.is_empty() {
213
+ self.insert_outstanding_activation(&act)?;
214
+ self.cache_manager.lock().touch(&act.run_id);
215
+ Ok(Some(act))
216
+ } else {
217
+ Ok(None)
218
+ }
206
219
  })
207
220
  {
208
- self.cache_manager.lock().touch(&act.run_id);
209
- Some(act)
221
+ act
210
222
  } else {
211
223
  self.request_eviction(
212
224
  &pending_info.run_id,
@@ -247,7 +259,7 @@ impl WorkflowTaskManager {
247
259
  run_id: &str,
248
260
  message: impl Into<String>,
249
261
  reason: EvictionReason,
250
- ) -> Option<u32> {
262
+ ) -> EvictionRequestResult {
251
263
  if self.workflow_machines.exists(run_id) {
252
264
  if !self.activation_has_eviction(run_id) {
253
265
  let message = message.into();
@@ -256,13 +268,17 @@ impl WorkflowTaskManager {
256
268
  self.pending_activations
257
269
  .notify_needs_eviction(run_id, message, reason);
258
270
  self.pending_activations_notifier.notify_waiters();
271
+ EvictionRequestResult::EvictionIssued(
272
+ self.workflow_machines
273
+ .get_task(run_id)
274
+ .map(|wt| wt.info.attempt),
275
+ )
276
+ } else {
277
+ EvictionRequestResult::EvictionAlreadyOutstanding
259
278
  }
260
- self.workflow_machines
261
- .get_task(run_id)
262
- .map(|wt| wt.info.attempt)
263
279
  } else {
264
280
  warn!(%run_id, "Eviction requested for unknown run");
265
- None
281
+ EvictionRequestResult::NotFound
266
282
  }
267
283
  }
268
284
 
@@ -304,9 +320,11 @@ impl WorkflowTaskManager {
304
320
  return NewWfTaskOutcome::TaskBuffered;
305
321
  };
306
322
 
323
+ let start_event_id = work.history.events.first().map(|e| e.event_id);
307
324
  debug!(
308
325
  task_token = %&work.task_token,
309
326
  history_length = %work.history.events.len(),
327
+ start_event_id = ?start_event_id,
310
328
  attempt = %work.attempt,
311
329
  run_id = %work.workflow_execution.run_id,
312
330
  "Applying new workflow task from server"
@@ -320,33 +338,45 @@ impl WorkflowTaskManager {
320
338
  .take()
321
339
  .map(|q| query_to_job(LEGACY_QUERY_ID.to_string(), q));
322
340
 
323
- let (info, mut next_activation) =
341
+ let (info, mut next_activation, mut pending_queries) =
324
342
  match self.instantiate_or_update_workflow(work, client).await {
325
- Ok((info, next_activation)) => (info, next_activation),
343
+ Ok(res) => res,
326
344
  Err(e) => {
327
345
  return NewWfTaskOutcome::Evict(e);
328
346
  }
329
347
  };
330
348
 
349
+ if !pending_queries.is_empty() && legacy_query.is_some() {
350
+ error!(
351
+ "Server issued both normal and legacy queries. This should not happen. Please \
352
+ file a bug report."
353
+ );
354
+ return NewWfTaskOutcome::Evict(WorkflowUpdateError {
355
+ source: WFMachinesError::Fatal(
356
+ "Server issued both normal and legacy query".to_string(),
357
+ ),
358
+ run_id: next_activation.run_id,
359
+ });
360
+ }
361
+
331
362
  // Immediately dispatch query activation if no other jobs
332
- let legacy_query = if next_activation.jobs.is_empty() {
333
- if let Some(lq) = legacy_query {
363
+ if let Some(lq) = legacy_query {
364
+ if next_activation.jobs.is_empty() {
334
365
  debug!("Dispatching legacy query {}", &lq);
335
366
  next_activation
336
367
  .jobs
337
368
  .push(workflow_activation_job::Variant::QueryWorkflow(lq).into());
369
+ } else {
370
+ pending_queries.push(lq);
338
371
  }
339
- None
340
- } else {
341
- legacy_query
342
- };
372
+ }
343
373
 
344
374
  self.workflow_machines
345
375
  .insert_wft(
346
376
  &next_activation.run_id,
347
377
  OutstandingTask {
348
378
  info,
349
- legacy_query,
379
+ pending_queries,
350
380
  start_time: task_start_time,
351
381
  },
352
382
  )
@@ -388,11 +418,11 @@ impl WorkflowTaskManager {
388
418
  return Ok(None);
389
419
  }
390
420
 
391
- let (task_token, is_leg_query_task, start_time) =
421
+ let (task_token, has_pending_query, start_time) =
392
422
  if let Some(entry) = self.workflow_machines.get_task(run_id) {
393
423
  (
394
424
  entry.info.task_token.clone(),
395
- entry.legacy_query.is_some(),
425
+ !entry.pending_queries.is_empty(),
396
426
  entry.start_time,
397
427
  )
398
428
  } else {
@@ -493,7 +523,7 @@ impl WorkflowTaskManager {
493
523
  let must_heartbeat = self
494
524
  .wait_for_local_acts_or_heartbeat(run_id, wft_heartbeat_deadline)
495
525
  .await;
496
- let is_query_playback = is_leg_query_task && query_responses.is_empty();
526
+ let is_query_playback = has_pending_query && query_responses.is_empty();
497
527
 
498
528
  // We only actually want to send commands back to the server if there are no more
499
529
  // pending activations and we are caught up on replay. We don't want to complete a wft
@@ -559,9 +589,10 @@ impl WorkflowTaskManager {
559
589
  FailedActivationOutcome::ReportLegacyQueryFailure(tt)
560
590
  } else {
561
591
  // Blow up any cached data associated with the workflow
562
- let should_report = self
563
- .request_eviction(run_id, failstr, reason)
564
- .map_or(true, |attempt| attempt <= 1);
592
+ let should_report = match self.request_eviction(run_id, failstr, reason) {
593
+ EvictionRequestResult::EvictionIssued(Some(attempt)) => attempt <= 1,
594
+ _ => false,
595
+ };
565
596
  if should_report {
566
597
  FailedActivationOutcome::Report(tt)
567
598
  } else {
@@ -578,7 +609,8 @@ impl WorkflowTaskManager {
578
609
  &self,
579
610
  poll_wf_resp: ValidPollWFTQResponse,
580
611
  client: Arc<WorkerClientBag>,
581
- ) -> Result<(WorkflowTaskInfo, WorkflowActivation), WorkflowUpdateError> {
612
+ ) -> Result<(WorkflowTaskInfo, WorkflowActivation, Vec<QueryWorkflow>), WorkflowUpdateError>
613
+ {
582
614
  let run_id = poll_wf_resp.workflow_execution.run_id.clone();
583
615
 
584
616
  let wft_info = WorkflowTaskInfo {
@@ -592,11 +624,16 @@ impl WorkflowTaskManager {
592
624
  .get(0)
593
625
  .map(|ev| ev.event_id > 1)
594
626
  .unwrap_or_default();
627
+ let poll_resp_is_incremental =
628
+ poll_resp_is_incremental || poll_wf_resp.history.events.is_empty();
629
+
630
+ let mut did_miss_cache = !poll_resp_is_incremental;
595
631
 
596
632
  let page_token = if !self.workflow_machines.exists(&run_id) && poll_resp_is_incremental {
597
633
  debug!(run_id=?run_id, "Workflow task has partial history, but workflow is not in \
598
634
  cache. Will fetch history");
599
635
  self.metrics.sticky_cache_miss();
636
+ did_miss_cache = true;
600
637
  NextPageToken::FetchFromStart
601
638
  } else {
602
639
  poll_wf_resp.next_page_token.into()
@@ -625,16 +662,26 @@ impl WorkflowTaskManager {
625
662
  .await
626
663
  {
627
664
  Ok(mut activation) => {
628
- // If there are in-poll queries, insert jobs for those queries into the activation
665
+ // If there are in-poll queries, insert jobs for those queries into the activation,
666
+ // but only if we hit the cache. If we didn't, those queries will need to be dealt
667
+ // with once replay is over
668
+ let mut pending_queries = vec![];
629
669
  if !poll_wf_resp.query_requests.is_empty() {
630
- let query_jobs = poll_wf_resp
631
- .query_requests
632
- .into_iter()
633
- .map(|q| workflow_activation_job::Variant::QueryWorkflow(q).into());
634
- activation.jobs.extend(query_jobs);
670
+ if !did_miss_cache {
671
+ let query_jobs = poll_wf_resp
672
+ .query_requests
673
+ .into_iter()
674
+ .map(|q| workflow_activation_job::Variant::QueryWorkflow(q).into());
675
+ activation.jobs.extend(query_jobs);
676
+ } else {
677
+ poll_wf_resp
678
+ .query_requests
679
+ .into_iter()
680
+ .for_each(|q| pending_queries.push(q));
681
+ }
635
682
  }
636
683
 
637
- Ok((wft_info, activation))
684
+ Ok((wft_info, activation, pending_queries))
638
685
  }
639
686
  Err(source) => Err(WorkflowUpdateError { source, run_id }),
640
687
  }
@@ -661,16 +708,18 @@ impl WorkflowTaskManager {
661
708
  // removed from the outstanding tasks map
662
709
  let retme = if !self.pending_activations.has_pending(run_id) {
663
710
  if !just_evicted {
664
- // Check if there was a legacy query which must be fulfilled, and if there is create
665
- // a new pending activation for it.
711
+ // Check if there was a pending query which must be fulfilled, and if there is
712
+ // create a new pending activation for it.
666
713
  if let Some(ref mut ot) = &mut *self
667
714
  .workflow_machines
668
715
  .get_task_mut(run_id)
669
716
  .expect("Machine must exist")
670
717
  {
671
- if let Some(query) = ot.legacy_query.take() {
672
- let na = create_query_activation(run_id.to_string(), [query]);
673
- self.pending_legacy_queries.push(na);
718
+ if !ot.pending_queries.is_empty() {
719
+ for query in ot.pending_queries.drain(..) {
720
+ let na = create_query_activation(run_id.to_string(), [query]);
721
+ self.pending_queries.push(na);
722
+ }
674
723
  self.pending_activations_notifier.notify_waiters();
675
724
  return false;
676
725
  }
@@ -725,7 +774,8 @@ impl WorkflowTaskManager {
725
774
  run_id: &str,
726
775
  resolved: LocalResolution,
727
776
  ) -> Result<(), WorkflowUpdateError> {
728
- self.workflow_machines
777
+ let result_was_important = self
778
+ .workflow_machines
729
779
  .access_sync(run_id, |wfm: &mut WorkflowManager| {
730
780
  wfm.notify_of_local_result(resolved)
731
781
  })?
@@ -734,7 +784,9 @@ impl WorkflowTaskManager {
734
784
  run_id: run_id.to_string(),
735
785
  })?;
736
786
 
737
- self.needs_activation(run_id);
787
+ if result_was_important {
788
+ self.needs_activation(run_id);
789
+ }
738
790
  Ok(())
739
791
  }
740
792
 
@@ -389,7 +389,7 @@ impl ActivityHalf {
389
389
  tokio::spawn(ACT_CANCEL_TOK.scope(ct, async move {
390
390
  let mut inputs = start.input;
391
391
  let arg = inputs.pop().unwrap_or_default();
392
- let output = (&act_fn.act_func)(arg).await;
392
+ let output = (act_fn.act_func)(arg).await;
393
393
  let result = match output {
394
394
  Ok(res) => ActivityExecutionResult::ok(res),
395
395
  Err(err) => match err.downcast::<ActivityCancelledError>() {
@@ -117,17 +117,13 @@ impl HistoryInfo {
117
117
  /// Remove events from the beginning of this history such that it looks like what would've been
118
118
  /// delivered on a sticky queue where the previously started task was the one before the last
119
119
  /// task in this history.
120
- ///
121
- /// This is not *fully* accurate in that it will include commands that were part of the last
122
- /// WFT completion, which the server would typically not include, but it's good enough for
123
- /// testing.
124
120
  pub fn make_incremental(&mut self) {
125
121
  let last_complete_ix = self
126
122
  .events
127
123
  .iter()
128
124
  .rposition(|he| he.event_type() == EventType::WorkflowTaskCompleted)
129
125
  .expect("Must be a WFT completed event in history");
130
- self.events.drain(0..=last_complete_ix);
126
+ self.events.drain(0..last_complete_ix);
131
127
  }
132
128
 
133
129
  pub fn events(&self) -> &[HistoryEvent] {
@@ -223,7 +219,7 @@ mod tests {
223
219
  fn incremental_works() {
224
220
  let t = single_timer("timer1");
225
221
  let hi = t.get_one_wft(2).unwrap();
226
- assert_eq!(hi.events().len(), 4);
227
- assert_eq!(hi.events()[0].event_id, 5);
222
+ assert_eq!(hi.events().len(), 5);
223
+ assert_eq!(hi.events()[0].event_id, 4);
228
224
  }
229
225
  }
@@ -1,5 +1,5 @@
1
1
  use std::time::Duration;
2
- use temporal_client::{WorkflowClientTrait, WorkflowOptions};
2
+ use temporal_client::WorkflowOptions;
3
3
  use temporal_sdk::{WfContext, WfExitValue, WorkflowResult};
4
4
  use temporal_sdk_core_protos::coresdk::workflow_commands::ContinueAsNewWorkflowExecution;
5
5
  use temporal_sdk_core_test_utils::CoreWfStarter;
@@ -33,13 +33,31 @@ async fn continue_as_new_happy_path() {
33
33
  )
34
34
  .await
35
35
  .unwrap();
36
+ // The four additional runs
37
+ worker.incr_expected_run_count(4);
36
38
  worker.run_until_done().await.unwrap();
39
+ }
37
40
 
38
- // Terminate the continued workflow
39
- starter
40
- .get_client()
41
- .await
42
- .terminate_workflow_execution(wf_name.to_owned(), None)
43
- .await
44
- .unwrap();
41
+ #[tokio::test]
42
+ async fn continue_as_new_multiple_concurrent() {
43
+ let wf_name = "continue_as_new_multiple_concurrent";
44
+ let mut starter = CoreWfStarter::new(wf_name);
45
+ starter.max_cached_workflows(3).max_wft(3);
46
+ let mut worker = starter.worker().await;
47
+ worker.register_wf(wf_name.to_string(), continue_as_new_wf);
48
+
49
+ let wf_names = (1..=20).map(|i| format!("{}-{}", wf_name, i));
50
+ for name in wf_names.clone() {
51
+ worker
52
+ .submit_wf(
53
+ name.to_string(),
54
+ wf_name.to_string(),
55
+ vec![[1].into()],
56
+ WorkflowOptions::default(),
57
+ )
58
+ .await
59
+ .unwrap();
60
+ }
61
+ worker.incr_expected_run_count(20 * 4);
62
+ worker.run_until_done().await.unwrap();
45
63
  }
package/src/errors.rs CHANGED
@@ -10,6 +10,8 @@ pub static SHUTDOWN_ERROR: OnceCell<Root<JsFunction>> = OnceCell::new();
10
10
  pub static NO_WORKER_ERROR: OnceCell<Root<JsFunction>> = OnceCell::new();
11
11
  /// Something unexpected happened, considered fatal
12
12
  pub static UNEXPECTED_ERROR: OnceCell<Root<JsFunction>> = OnceCell::new();
13
+ /// Used in different parts of the project to signal that something unexpected has happened
14
+ pub static ILLEGAL_STATE_ERROR: OnceCell<Root<JsFunction>> = OnceCell::new();
13
15
 
14
16
  static ALREADY_REGISTERED_ERRORS: OnceCell<bool> = OnceCell::new();
15
17
 
@@ -70,9 +72,9 @@ pub fn register_errors(mut cx: FunctionContext) -> JsResult<JsUndefined> {
70
72
  let res = ALREADY_REGISTERED_ERRORS.set(true);
71
73
  if res.is_err() {
72
74
  // Don't do anything if errors are already registered
73
- return Ok(cx.undefined())
75
+ return Ok(cx.undefined());
74
76
  }
75
-
77
+
76
78
  let mapping = cx.argument::<JsObject>(0)?;
77
79
  let shutdown_error = mapping
78
80
  .get(&mut cx, "ShutdownError")?
@@ -90,11 +92,16 @@ pub fn register_errors(mut cx: FunctionContext) -> JsResult<JsUndefined> {
90
92
  .get(&mut cx, "UnexpectedError")?
91
93
  .downcast_or_throw::<JsFunction, FunctionContext>(&mut cx)?
92
94
  .root(&mut cx);
95
+ let illegal_state_error = mapping
96
+ .get(&mut cx, "IllegalStateError")?
97
+ .downcast_or_throw::<JsFunction, FunctionContext>(&mut cx)?
98
+ .root(&mut cx);
93
99
 
94
100
  TRANSPORT_ERROR.get_or_try_init(|| Ok(transport_error))?;
95
101
  SHUTDOWN_ERROR.get_or_try_init(|| Ok(shutdown_error))?;
96
102
  NO_WORKER_ERROR.get_or_try_init(|| Ok(no_worker_error))?;
97
103
  UNEXPECTED_ERROR.get_or_try_init(|| Ok(unexpected_error))?;
104
+ ILLEGAL_STATE_ERROR.get_or_try_init(|| Ok(illegal_state_error))?;
98
105
 
99
106
  Ok(cx.undefined())
100
107
  }
package/src/lib.rs CHANGED
@@ -8,6 +8,7 @@ use once_cell::sync::OnceCell;
8
8
  use opentelemetry::trace::{FutureExt, SpanContext, TraceContextExt};
9
9
  use prost::Message;
10
10
  use std::{
11
+ cell::RefCell,
11
12
  fmt::Display,
12
13
  future::Future,
13
14
  sync::Arc,
@@ -135,7 +136,7 @@ struct Client {
135
136
  core_client: Arc<RawClient>,
136
137
  }
137
138
 
138
- type BoxedClient = JsBox<Client>;
139
+ type BoxedClient = JsBox<RefCell<Option<Client>>>;
139
140
  impl Finalize for Client {}
140
141
 
141
142
  /// Worker struct, hold a reference for the channel sender responsible for sending requests from
@@ -291,10 +292,10 @@ fn start_bridge_loop(event_queue: Arc<EventQueue>, receiver: &mut UnboundedRecei
291
292
  }
292
293
  Ok(client) => {
293
294
  send_result(event_queue.clone(), callback, |cx| {
294
- Ok(cx.boxed(Client {
295
+ Ok(cx.boxed(RefCell::new(Some(Client {
295
296
  runtime,
296
297
  core_client: Arc::new(client),
297
- }))
298
+ }))))
298
299
  });
299
300
  }
300
301
  }
@@ -590,15 +591,23 @@ fn worker_new(mut cx: FunctionContext) -> JsResult<JsUndefined> {
590
591
  let callback = cx.argument::<JsFunction>(2)?;
591
592
 
592
593
  let config = worker_options.as_worker_config(&mut cx)?;
593
-
594
- let request = Request::InitWorker {
595
- client: client.core_client.clone(),
596
- runtime: client.runtime.clone(),
597
- config,
598
- callback: callback.root(&mut cx),
599
- };
600
- if let Err(err) = client.runtime.sender.send(request) {
601
- callback_with_unexpected_error(&mut cx, callback, err)?;
594
+ match &*client.borrow() {
595
+ None => {
596
+ callback_with_error(&mut cx, callback, move |cx| {
597
+ UNEXPECTED_ERROR.from_string(cx, "Tried to use closed Client".to_string())
598
+ })?;
599
+ }
600
+ Some(client) => {
601
+ let request = Request::InitWorker {
602
+ client: client.core_client.clone(),
603
+ runtime: client.runtime.clone(),
604
+ config,
605
+ callback: callback.root(&mut cx),
606
+ };
607
+ if let Err(err) = client.runtime.sender.send(request) {
608
+ callback_with_unexpected_error(&mut cx, callback, err)?;
609
+ };
610
+ }
602
611
  };
603
612
 
604
613
  Ok(cx.undefined())
@@ -783,13 +792,26 @@ fn worker_record_activity_heartbeat(mut cx: FunctionContext) -> JsResult<JsUndef
783
792
  fn worker_shutdown(mut cx: FunctionContext) -> JsResult<JsUndefined> {
784
793
  let worker = cx.argument::<BoxedWorker>(0)?;
785
794
  let callback = cx.argument::<JsFunction>(1)?;
786
- match worker.runtime.sender.send(Request::ShutdownWorker {
795
+ if let Err(err) = worker.runtime.sender.send(Request::ShutdownWorker {
787
796
  worker: worker.core_worker.clone(),
788
797
  callback: callback.root(&mut cx),
789
798
  }) {
790
- Err(err) => cx.throw_error(format!("{}", err)),
791
- _ => Ok(cx.undefined()),
792
- }
799
+ UNEXPECTED_ERROR
800
+ .from_error(&mut cx, err)
801
+ .and_then(|err| cx.throw(err))?;
802
+ };
803
+ Ok(cx.undefined())
804
+ }
805
+
806
+ /// Drop a reference to a Client, once all references are dropped, the Client will be closed.
807
+ fn client_close(mut cx: FunctionContext) -> JsResult<JsUndefined> {
808
+ let client = cx.argument::<BoxedClient>(0)?;
809
+ if client.replace(None).is_none() {
810
+ ILLEGAL_STATE_ERROR
811
+ .from_error(&mut cx, "Client already closed")
812
+ .and_then(|err| cx.throw(err))?;
813
+ };
814
+ Ok(cx.undefined())
793
815
  }
794
816
 
795
817
  /// Convert Rust SystemTime into a JS array with 2 numbers (seconds, nanos)
@@ -824,6 +846,7 @@ fn main(mut cx: ModuleContext) -> NeonResult<()> {
824
846
  cx.export_function("newWorker", worker_new)?;
825
847
  cx.export_function("newReplayWorker", replay_worker_new)?;
826
848
  cx.export_function("workerShutdown", worker_shutdown)?;
849
+ cx.export_function("clientClose", client_close)?;
827
850
  cx.export_function("runtimeShutdown", runtime_shutdown)?;
828
851
  cx.export_function("pollLogs", poll_logs)?;
829
852
  cx.export_function(