1use a3s_box_core::error::{BoxError, Result};
4use serde::{Deserialize, Serialize};
5
6pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
12
13pub const FRAME_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
17
18#[derive(Serialize, Deserialize)]
23#[serde(tag = "op", rename_all = "snake_case")]
24pub enum PoolRequest {
25 Run(PoolRunRequest),
26 Status,
27 Stop,
28 Lease(PoolLeaseRequest),
29 Exec(PoolLeaseExecRequest),
30 Release(PoolLeaseReleaseRequest),
31}
32
33#[derive(Serialize, Deserialize)]
34pub struct PoolRunRequest {
35 #[serde(default)]
37 pub image: Option<String>,
38 #[serde(default)]
40 pub user: Option<String>,
41 #[serde(default)]
43 pub workdir: Option<String>,
44 #[serde(default)]
46 pub rootfs: Option<String>,
47 #[serde(default)]
49 pub env: Vec<String>,
50 #[serde(default)]
52 pub volumes: Vec<String>,
53 #[serde(default)]
55 pub vcpus: Option<u32>,
56 #[serde(default)]
58 pub memory_mb: Option<u32>,
59 #[serde(default)]
61 pub exec: bool,
62 #[serde(default)]
64 pub timeout_ns: Option<u64>,
65 pub cmd: Vec<String>,
66}
67
68#[derive(Serialize, Deserialize)]
69pub struct PoolRunResponse {
70 pub stdout: Vec<u8>,
71 pub stderr: Vec<u8>,
72 pub exit_code: i32,
73 pub error: Option<String>,
74}
75
76#[derive(Serialize, Deserialize)]
77pub struct PoolLeaseRequest {
78 #[serde(default)]
80 pub image: Option<String>,
81 #[serde(default)]
83 pub volumes: Vec<String>,
84 #[serde(default)]
86 pub vcpus: Option<u32>,
87 #[serde(default)]
89 pub memory_mb: Option<u32>,
90}
91
92#[derive(Serialize, Deserialize)]
93pub struct PoolLeaseResponse {
94 pub lease_id: Option<String>,
95 pub error: Option<String>,
96}
97
98#[derive(Serialize, Deserialize)]
99pub struct PoolLeaseExecRequest {
100 pub lease_id: String,
101 pub cmd: Vec<String>,
102 #[serde(default)]
103 pub timeout_ns: Option<u64>,
104 #[serde(default)]
105 pub env: Vec<String>,
106 #[serde(default)]
107 pub working_dir: Option<String>,
108 #[serde(default)]
109 pub rootfs: Option<String>,
110 #[serde(default)]
111 pub stdin: Option<Vec<u8>>,
112 #[serde(default)]
113 pub user: Option<String>,
114}
115
116#[derive(Serialize, Deserialize)]
117pub struct PoolLeaseReleaseRequest {
118 pub lease_id: String,
119}
120
121#[derive(Serialize, Deserialize)]
122pub struct PoolLeaseReleaseResponse {
123 pub error: Option<String>,
124}
125
126#[derive(Serialize, Deserialize)]
128pub struct PoolImageStat {
129 pub image: String,
130 pub pool: String,
131 #[serde(default)]
133 pub max: usize,
134 pub idle: usize,
135 #[serde(default)]
137 pub active: usize,
138 #[serde(default)]
140 pub leased: usize,
141 pub total_created: u64,
142 pub total_acquired: u64,
143 pub total_evicted: u64,
144}
145
146#[derive(Serialize, Deserialize)]
147pub struct PoolStatusResponse {
148 pub images: Vec<PoolImageStat>,
149}
150
151#[derive(Serialize, Deserialize)]
152pub struct PoolStopResponse {
153 pub error: Option<String>,
154}
155
156pub struct PoolClientRun {
157 pub socket: String,
158 pub image: Option<String>,
159 pub user: Option<String>,
160 pub workdir: Option<String>,
161 pub rootfs: Option<String>,
162 pub env: Vec<String>,
163 pub volumes: Vec<String>,
164 pub vcpus: u32,
165 pub memory_mb: u32,
166 pub exec: bool,
167 pub timeout_ns: Option<u64>,
168 pub cmd: Vec<String>,
169}
170
171pub struct PoolClientOutput {
172 pub stdout: Vec<u8>,
173 pub stderr: Vec<u8>,
174 pub exit_code: i32,
175}
176
177pub struct PoolLeaseClient {
178 socket: String,
179 lease_id: String,
180 released: bool,
181}
182
183impl PoolLeaseClient {
184 pub fn lease_id(&self) -> &str {
185 &self.lease_id
186 }
187
188 pub async fn acquire(req: PoolClientLease) -> Result<Self> {
189 let response = lease_client(&req).await?;
190 let lease_id = response.lease_id.ok_or_else(|| {
191 BoxError::PoolError("pool lease response did not include a lease id".to_string())
192 })?;
193 Ok(Self {
194 socket: req.socket,
195 lease_id,
196 released: false,
197 })
198 }
199
200 pub async fn exec(&self, req: PoolLeaseExec) -> Result<PoolClientOutput> {
201 lease_exec_client(
202 &self.socket,
203 PoolLeaseExecRequest {
204 lease_id: self.lease_id.clone(),
205 cmd: req.cmd,
206 timeout_ns: req.timeout_ns,
207 env: req.env,
208 working_dir: req.working_dir,
209 rootfs: req.rootfs,
210 stdin: req.stdin,
211 user: req.user,
212 },
213 )
214 .await
215 }
216
217 pub async fn release(mut self) -> Result<()> {
218 let result = release_client(&self.socket, &self.lease_id).await;
219 if result.is_ok() {
220 self.released = true;
221 }
222 result
223 }
224}
225
226impl Drop for PoolLeaseClient {
227 fn drop(&mut self) {
228 #[cfg(not(windows))]
229 if !self.released {
230 release_client_blocking_best_effort(&self.socket, &self.lease_id);
231 }
232 }
233}
234
235pub struct PoolClientLease {
236 pub socket: String,
237 pub image: Option<String>,
238 pub volumes: Vec<String>,
239 pub vcpus: u32,
240 pub memory_mb: u32,
241}
242
243pub struct PoolLeaseExec {
244 pub cmd: Vec<String>,
245 pub timeout_ns: Option<u64>,
246 pub env: Vec<String>,
247 pub working_dir: Option<String>,
248 pub rootfs: Option<String>,
249 pub stdin: Option<Vec<u8>>,
250 pub user: Option<String>,
251}
252
253#[cfg(not(windows))]
254pub async fn run_client(req: PoolClientRun) -> Result<PoolClientOutput> {
255 use tokio::net::UnixStream;
256
257 let mut stream = UnixStream::connect(&req.socket).await.map_err(|e| {
258 BoxError::PoolError(format!(
259 "Failed to connect to pool daemon at {} ({}). Is `a3s-box pool start` running?",
260 req.socket, e
261 ))
262 })?;
263
264 write_frame(
265 &mut stream,
266 &serde_json::to_vec(&PoolRequest::Run(PoolRunRequest {
267 image: req.image,
268 user: req.user,
269 workdir: req.workdir,
270 rootfs: req.rootfs,
271 env: req.env,
272 volumes: req.volumes,
273 vcpus: Some(req.vcpus),
274 memory_mb: Some(req.memory_mb),
275 exec: req.exec,
276 timeout_ns: req.timeout_ns,
277 cmd: req.cmd,
278 }))?,
279 )
280 .await?;
281 let resp: PoolRunResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?;
282
283 if let Some(err) = resp.error {
284 return Err(BoxError::PoolError(err));
285 }
286
287 Ok(PoolClientOutput {
288 stdout: resp.stdout,
289 stderr: resp.stderr,
290 exit_code: resp.exit_code,
291 })
292}
293
294#[cfg(windows)]
295pub async fn run_client(_req: PoolClientRun) -> Result<PoolClientOutput> {
296 Err(BoxError::PoolError(
297 "`pool run` is not supported on Windows".to_string(),
298 ))
299}
300
301#[cfg(not(windows))]
302pub async fn status_client(socket: &str) -> Result<PoolStatusResponse> {
303 use tokio::net::UnixStream;
304
305 let mut stream = UnixStream::connect(socket).await.map_err(|e| {
306 BoxError::PoolError(format!("Failed to connect to pool daemon at {socket}: {e}"))
307 })?;
308 write_frame(&mut stream, &serde_json::to_vec(&PoolRequest::Status)?).await?;
309 Ok(serde_json::from_slice(&read_frame(&mut stream).await?)?)
310}
311
312#[cfg(not(windows))]
313pub async fn stop_client(socket: &str) -> Result<()> {
314 use tokio::net::UnixStream;
315
316 let mut stream = UnixStream::connect(socket).await.map_err(|e| {
317 BoxError::PoolError(format!("Failed to connect to pool daemon at {socket}: {e}"))
318 })?;
319 write_frame(&mut stream, &serde_json::to_vec(&PoolRequest::Stop)?).await?;
320 let resp: PoolStopResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?;
321 if let Some(error) = resp.error {
322 return Err(BoxError::PoolError(error));
323 }
324 Ok(())
325}
326
327#[cfg(windows)]
328pub async fn stop_client(_socket: &str) -> Result<()> {
329 Err(BoxError::PoolError(
330 "`pool stop` is not supported on Windows".to_string(),
331 ))
332}
333
334#[cfg(not(windows))]
335async fn lease_client(req: &PoolClientLease) -> Result<PoolLeaseResponse> {
336 use tokio::net::UnixStream;
337
338 let mut stream = UnixStream::connect(&req.socket).await.map_err(|e| {
339 BoxError::PoolError(format!(
340 "Failed to connect to pool daemon at {} ({}). Is `a3s-box pool start` running?",
341 req.socket, e
342 ))
343 })?;
344 write_frame(
345 &mut stream,
346 &serde_json::to_vec(&PoolRequest::Lease(PoolLeaseRequest {
347 image: req.image.clone(),
348 volumes: req.volumes.clone(),
349 vcpus: Some(req.vcpus),
350 memory_mb: Some(req.memory_mb),
351 }))?,
352 )
353 .await?;
354 let resp: PoolLeaseResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?;
355 if let Some(error) = resp.error.as_ref() {
356 return Err(BoxError::PoolError(error.clone()));
357 }
358 Ok(resp)
359}
360
361#[cfg(windows)]
362async fn lease_client(_req: &PoolClientLease) -> Result<PoolLeaseResponse> {
363 Err(BoxError::PoolError(
364 "warm-pool leases are not supported on Windows".to_string(),
365 ))
366}
367
368#[cfg(not(windows))]
369async fn lease_exec_client(socket: &str, req: PoolLeaseExecRequest) -> Result<PoolClientOutput> {
370 use tokio::net::UnixStream;
371
372 let mut stream = UnixStream::connect(socket).await.map_err(|e| {
373 BoxError::PoolError(format!("Failed to connect to pool daemon at {socket}: {e}"))
374 })?;
375 write_frame(&mut stream, &serde_json::to_vec(&PoolRequest::Exec(req))?).await?;
376 let resp: PoolRunResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?;
377 if let Some(error) = resp.error {
378 return Err(BoxError::PoolError(error));
379 }
380 Ok(PoolClientOutput {
381 stdout: resp.stdout,
382 stderr: resp.stderr,
383 exit_code: resp.exit_code,
384 })
385}
386
387#[cfg(windows)]
388async fn lease_exec_client(_socket: &str, _req: PoolLeaseExecRequest) -> Result<PoolClientOutput> {
389 Err(BoxError::PoolError(
390 "warm-pool leases are not supported on Windows".to_string(),
391 ))
392}
393
394#[cfg(not(windows))]
395async fn release_client(socket: &str, lease_id: &str) -> Result<()> {
396 use tokio::net::UnixStream;
397
398 let mut stream = UnixStream::connect(socket).await.map_err(|e| {
399 BoxError::PoolError(format!("Failed to connect to pool daemon at {socket}: {e}"))
400 })?;
401 write_frame(
402 &mut stream,
403 &serde_json::to_vec(&PoolRequest::Release(PoolLeaseReleaseRequest {
404 lease_id: lease_id.to_string(),
405 }))?,
406 )
407 .await?;
408 let resp: PoolLeaseReleaseResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?;
409 if let Some(error) = resp.error {
410 return Err(BoxError::PoolError(error));
411 }
412 Ok(())
413}
414
415#[cfg(windows)]
416async fn release_client(_socket: &str, _lease_id: &str) -> Result<()> {
417 Err(BoxError::PoolError(
418 "warm-pool leases are not supported on Windows".to_string(),
419 ))
420}
421
422#[cfg(not(windows))]
423fn release_client_blocking_best_effort(socket: &str, lease_id: &str) {
424 use std::io::Write;
425 use std::os::unix::net::UnixStream;
426 use std::time::Duration;
427
428 let Ok(mut stream) = UnixStream::connect(socket) else {
429 return;
430 };
431 let timeout = Some(Duration::from_millis(500));
432 let _ = stream.set_read_timeout(timeout);
433 let _ = stream.set_write_timeout(timeout);
434
435 let Ok(payload) = serde_json::to_vec(&PoolRequest::Release(PoolLeaseReleaseRequest {
436 lease_id: lease_id.to_string(),
437 })) else {
438 return;
439 };
440 let _ = stream
441 .write_all(&(payload.len() as u32).to_le_bytes())
442 .and_then(|_| stream.write_all(&payload))
443 .and_then(|_| stream.flush());
444}
445
446#[cfg(not(windows))]
448pub async fn write_frame<W>(w: &mut W, data: &[u8]) -> std::io::Result<()>
449where
450 W: tokio::io::AsyncWrite + Unpin,
451{
452 use tokio::io::AsyncWriteExt;
453
454 if data.len() > MAX_FRAME_SIZE {
455 return Err(std::io::Error::new(
456 std::io::ErrorKind::InvalidInput,
457 format!(
458 "pool frame is {} bytes; maximum is {} bytes",
459 data.len(),
460 MAX_FRAME_SIZE
461 ),
462 ));
463 }
464 w.write_all(&(data.len() as u32).to_le_bytes()).await?;
465 w.write_all(data).await?;
466 w.flush().await
467}
468
469#[cfg(not(windows))]
470pub async fn read_frame<R>(r: &mut R) -> std::io::Result<Vec<u8>>
471where
472 R: tokio::io::AsyncRead + Unpin,
473{
474 use tokio::io::AsyncReadExt;
475
476 let mut len = [0u8; 4];
477 r.read_exact(&mut len).await?;
478 let frame_len = u32::from_le_bytes(len) as usize;
479 if frame_len > MAX_FRAME_SIZE {
480 return Err(std::io::Error::new(
481 std::io::ErrorKind::InvalidData,
482 format!(
483 "pool frame is {} bytes; maximum is {} bytes",
484 frame_len, MAX_FRAME_SIZE
485 ),
486 ));
487 }
488 let mut buf = vec![0u8; frame_len];
489 r.read_exact(&mut buf).await?;
490 Ok(buf)
491}
492
493#[cfg(not(windows))]
495pub async fn read_frame_with_timeout<R>(
496 r: &mut R,
497 timeout: std::time::Duration,
498) -> std::io::Result<Vec<u8>>
499where
500 R: tokio::io::AsyncRead + Unpin,
501{
502 tokio::time::timeout(timeout, read_frame(r))
503 .await
504 .map_err(|_| {
505 std::io::Error::new(
506 std::io::ErrorKind::TimedOut,
507 "timed out waiting for pool frame",
508 )
509 })?
510}
511
512#[cfg(test)]
513mod tests {
514 #[cfg(not(windows))]
515 use super::{read_frame, read_frame_with_timeout, write_frame, MAX_FRAME_SIZE};
516
517 #[cfg(not(windows))]
518 #[tokio::test]
519 async fn oversized_frame_is_rejected_before_allocation() {
520 use tokio::io::AsyncWriteExt;
521
522 let (mut writer, mut reader) = tokio::io::duplex(64);
523 writer
524 .write_all(&((MAX_FRAME_SIZE as u32) + 1).to_le_bytes())
525 .await
526 .unwrap();
527
528 let error = read_frame(&mut reader).await.unwrap_err();
529 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
530 assert!(error.to_string().contains("maximum"));
531 }
532
533 #[cfg(not(windows))]
534 #[tokio::test]
535 async fn oversized_frame_is_rejected_before_write() {
536 let (mut writer, _) = tokio::io::duplex(64);
537 let payload = vec![0u8; MAX_FRAME_SIZE + 1];
538
539 let error = write_frame(&mut writer, &payload).await.unwrap_err();
540 assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
541 }
542
543 #[cfg(not(windows))]
544 #[tokio::test]
545 async fn frame_read_timeout_returns_a_timed_out_error() {
546 let (_writer, mut reader) = tokio::io::duplex(64);
547
548 let error = read_frame_with_timeout(&mut reader, std::time::Duration::from_millis(10))
549 .await
550 .unwrap_err();
551 assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
552 }
553
554 #[cfg(not(windows))]
555 #[test]
556 fn lease_drop_releases_synchronously() {
557 use super::*;
558 use std::io::Read;
559 use std::os::unix::net::UnixListener;
560
561 let tmp = tempfile::TempDir::new().unwrap();
562 let socket = tmp.path().join("pool.sock");
563 let listener = UnixListener::bind(&socket).unwrap();
564 let socket_arg = socket.to_string_lossy().to_string();
565
566 let server = std::thread::spawn(move || {
567 let (mut stream, _) = listener.accept().unwrap();
568 let mut len = [0_u8; 4];
569 stream.read_exact(&mut len).unwrap();
570 let mut request = vec![0_u8; u32::from_le_bytes(len) as usize];
571 stream.read_exact(&mut request).unwrap();
572 let request: PoolRequest = serde_json::from_slice(&request).unwrap();
573 match request {
574 PoolRequest::Release(req) => assert_eq!(req.lease_id, "lease-drop"),
575 _ => panic!("drop should send release request"),
576 }
577 });
578
579 let lease = PoolLeaseClient {
580 socket: socket_arg,
581 lease_id: "lease-drop".to_string(),
582 released: false,
583 };
584 drop(lease);
585
586 server.join().unwrap();
587 }
588}