Skip to content

Commit 505beb0

Browse files
feat(hooks): add 5 second timeout for user-provided hook scripts (#2462)
- Add hard timeout to prevent frontend from hanging indefinitely when waiting for hooks like beforeListeningStarted - Properly kill and reap child process on timeout to avoid zombie processes - Add tokio time feature for timeout support Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yujonglee <[email protected]>
1 parent c856414 commit 505beb0

File tree

3 files changed

+170
-118
lines changed

3 files changed

+170
-118
lines changed

plugins/hooks/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,4 @@ dirs = { workspace = true }
2929
futures-util = { workspace = true }
3030
shellexpand = { workspace = true }
3131
thiserror = { workspace = true }
32-
tokio = { workspace = true, features = ["process"] }
32+
tokio = { workspace = true, features = ["process", "time"] }

plugins/hooks/js/bindings.gen.ts

Lines changed: 110 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,142 @@
11
// @ts-nocheck
2-
/** tauri-specta globals **/
3-
import { Channel as TAURI_CHANNEL, invoke as TAURI_INVOKE } from "@tauri-apps/api/core";
4-
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
5-
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
62

73
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
84

95
/** user-defined commands **/
106

7+
118
export const commands = {
12-
async runEventHooks(event: HookEvent): Promise<Result<HookResult[], string>> {
9+
async runEventHooks(event: HookEvent) : Promise<Result<HookResult[], string>> {
1310
try {
14-
return {
15-
status: "ok",
16-
data: await TAURI_INVOKE("plugin:hooks|run_event_hooks", { event }),
17-
};
18-
} catch (e) {
19-
if (e instanceof Error) throw e;
20-
else return { status: "error", error: e as any };
21-
}
22-
},
23-
};
11+
return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|run_event_hooks", { event }) };
12+
} catch (e) {
13+
if(e instanceof Error) throw e;
14+
else return { status: "error", error: e as any };
15+
}
16+
}
17+
}
2418

2519
/** user-defined events **/
2620

21+
22+
2723
/** user-defined constants **/
2824

25+
26+
2927
/** user-defined types **/
3028

3129
/**
3230
* Arguments passed to hooks triggered after listening stops.
3331
*/
34-
export type AfterListeningStoppedArgs = {
35-
/**
36-
* Path to the resource directory.
37-
*/
38-
resource_dir: string;
39-
/**
40-
* Application-specific Hyprnote data.
41-
*/
42-
app_hyprnote: string;
43-
/**
44-
* Optional meeting-specific data.
45-
*/
46-
app_meeting?: string | null;
47-
};
32+
export type AfterListeningStoppedArgs = {
33+
/**
34+
* Path to the resource directory.
35+
*/
36+
resource_dir: string;
37+
/**
38+
* Application-specific Hyprnote data.
39+
*/
40+
app_hyprnote: string;
41+
/**
42+
* Optional meeting-specific data.
43+
*/
44+
app_meeting?: string | null }
4845
/**
4946
* Arguments passed to hooks triggered before listening starts.
5047
*/
51-
export type BeforeListeningStartedArgs = {
52-
/**
53-
* Path to the resource directory.
54-
*/
55-
resource_dir: string;
56-
/**
57-
* Application-specific Hyprnote data.
58-
*/
59-
app_hyprnote: string;
60-
/**
61-
* Optional meeting-specific data.
62-
*/
63-
app_meeting?: string | null;
64-
};
48+
export type BeforeListeningStartedArgs = {
49+
/**
50+
* Path to the resource directory.
51+
*/
52+
resource_dir: string;
53+
/**
54+
* Application-specific Hyprnote data.
55+
*/
56+
app_hyprnote: string;
57+
/**
58+
* Optional meeting-specific data.
59+
*/
60+
app_meeting?: string | null }
6561
/**
6662
* Defines a single hook to be executed on an event.
6763
*/
68-
export type HookDefinition = {
69-
/**
70-
* Shell command to execute when the hook is triggered.
71-
*/
72-
command: string;
73-
};
74-
export type HookEvent =
75-
| { afterListeningStopped: { args: AfterListeningStoppedArgs } }
76-
| { beforeListeningStarted: { args: BeforeListeningStartedArgs } };
77-
export type HookResult = {
78-
command: string;
79-
success: boolean;
80-
exit_code: number | null;
81-
stdout: string;
82-
stderr: string;
83-
};
64+
export type HookDefinition = {
65+
/**
66+
* Shell command to execute when the hook is triggered.
67+
*/
68+
command: string }
69+
export type HookEvent = { afterListeningStopped: { args: AfterListeningStoppedArgs } } | { beforeListeningStarted: { args: BeforeListeningStartedArgs } }
70+
export type HookResult = { command: string; success: boolean; exit_code: number | null; stdout: string; stderr: string }
8471
/**
8572
* Configuration for hook execution.
8673
*/
87-
export type HooksConfig = {
88-
/**
89-
* Configuration schema version.
90-
*/
91-
version: number;
92-
/**
93-
* Map of event names to their associated hook definitions.
94-
*/
95-
hooks?: Partial<{ [key in string]: HookDefinition[] }>;
96-
};
74+
export type HooksConfig = {
75+
/**
76+
* Configuration schema version.
77+
*/
78+
version: number;
79+
/**
80+
* Map of event names to their associated hook definitions.
81+
*/
82+
on?: Partial<{ [key in string]: HookDefinition[] }> }
83+
84+
/** tauri-specta globals **/
85+
86+
import {
87+
invoke as TAURI_INVOKE,
88+
Channel as TAURI_CHANNEL,
89+
} from "@tauri-apps/api/core";
90+
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
91+
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
9792

9893
type __EventObj__<T> = {
99-
listen: (cb: TAURI_API_EVENT.EventCallback<T>) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
100-
once: (cb: TAURI_API_EVENT.EventCallback<T>) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
101-
emit: null extends T
102-
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
103-
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
94+
listen: (
95+
cb: TAURI_API_EVENT.EventCallback<T>,
96+
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
97+
once: (
98+
cb: TAURI_API_EVENT.EventCallback<T>,
99+
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
100+
emit: null extends T
101+
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
102+
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
104103
};
105104

106-
export type Result<T, E> = { status: "ok"; data: T } | { status: "error"; error: E };
107-
108-
function __makeEvents__<T extends Record<string, any>>(mappings: Record<keyof T, string>) {
109-
return new Proxy(
110-
{} as unknown as {
111-
[K in keyof T]: __EventObj__<T[K]> & {
112-
(handle: __WebviewWindow__): __EventObj__<T[K]>;
113-
};
114-
},
115-
{
116-
get: (_, event) => {
117-
const name = mappings[event as keyof T];
118-
119-
return new Proxy((() => {}) as any, {
120-
apply: (_, __, [window]: [__WebviewWindow__]) => ({
121-
listen: (arg: any) => window.listen(name, arg),
122-
once: (arg: any) => window.once(name, arg),
123-
emit: (arg: any) => window.emit(name, arg),
124-
}),
125-
get: (_, command: keyof __EventObj__<any>) => {
126-
switch (command) {
127-
case "listen":
128-
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
129-
case "once":
130-
return (arg: any) => TAURI_API_EVENT.once(name, arg);
131-
case "emit":
132-
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
133-
}
134-
},
135-
});
136-
},
137-
},
138-
);
105+
export type Result<T, E> =
106+
| { status: "ok"; data: T }
107+
| { status: "error"; error: E };
108+
109+
function __makeEvents__<T extends Record<string, any>>(
110+
mappings: Record<keyof T, string>,
111+
) {
112+
return new Proxy(
113+
{} as unknown as {
114+
[K in keyof T]: __EventObj__<T[K]> & {
115+
(handle: __WebviewWindow__): __EventObj__<T[K]>;
116+
};
117+
},
118+
{
119+
get: (_, event) => {
120+
const name = mappings[event as keyof T];
121+
122+
return new Proxy((() => {}) as any, {
123+
apply: (_, __, [window]: [__WebviewWindow__]) => ({
124+
listen: (arg: any) => window.listen(name, arg),
125+
once: (arg: any) => window.once(name, arg),
126+
emit: (arg: any) => window.emit(name, arg),
127+
}),
128+
get: (_, command: keyof __EventObj__<any>) => {
129+
switch (command) {
130+
case "listen":
131+
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
132+
case "once":
133+
return (arg: any) => TAURI_API_EVENT.once(name, arg);
134+
case "emit":
135+
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
136+
}
137+
},
138+
});
139+
},
140+
},
141+
);
139142
}

plugins/hooks/src/runner.rs

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use std::ffi::OsString;
2+
use std::time::Duration;
23

34
use crate::{config::HooksConfig, event::HookEvent};
45

6+
const HOOK_TIMEOUT: Duration = Duration::from_secs(5);
7+
58
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
69
pub struct HookResult {
710
pub command: String,
@@ -61,21 +64,67 @@ async fn execute_hook(command: &str, args: &[OsString]) -> HookResult {
6164

6265
cmd.args(args);
6366

64-
match cmd.output().await {
65-
Ok(output) => HookResult {
66-
command: command.to_string(),
67-
success: output.status.success(),
68-
exit_code: output.status.code(),
69-
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
70-
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
71-
},
72-
Err(e) => HookResult {
67+
let mut child = match cmd
68+
.stdout(std::process::Stdio::piped())
69+
.stderr(std::process::Stdio::piped())
70+
.spawn()
71+
{
72+
Ok(child) => child,
73+
Err(e) => {
74+
return HookResult {
75+
command: command.to_string(),
76+
success: false,
77+
exit_code: None,
78+
stdout: String::new(),
79+
stderr: format!("failed to spawn command: {}", e),
80+
};
81+
}
82+
};
83+
84+
match tokio::time::timeout(HOOK_TIMEOUT, child.wait()).await {
85+
Ok(Ok(status)) => {
86+
let stdout = match child.stdout.take() {
87+
Some(mut stdout) => {
88+
let mut buf = Vec::new();
89+
let _ = tokio::io::AsyncReadExt::read_to_end(&mut stdout, &mut buf).await;
90+
String::from_utf8_lossy(&buf).to_string()
91+
}
92+
None => String::new(),
93+
};
94+
let stderr = match child.stderr.take() {
95+
Some(mut stderr) => {
96+
let mut buf = Vec::new();
97+
let _ = tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await;
98+
String::from_utf8_lossy(&buf).to_string()
99+
}
100+
None => String::new(),
101+
};
102+
HookResult {
103+
command: command.to_string(),
104+
success: status.success(),
105+
exit_code: status.code(),
106+
stdout,
107+
stderr,
108+
}
109+
}
110+
Ok(Err(e)) => HookResult {
73111
command: command.to_string(),
74112
success: false,
75113
exit_code: None,
76114
stdout: String::new(),
77-
stderr: format!("failed to spawn command: {}", e),
115+
stderr: format!("failed to wait for command: {}", e),
78116
},
117+
Err(_) => {
118+
let _ = child.kill().await;
119+
let _ = child.wait().await;
120+
HookResult {
121+
command: command.to_string(),
122+
success: false,
123+
exit_code: None,
124+
stdout: String::new(),
125+
stderr: format!("hook timed out after {} seconds", HOOK_TIMEOUT.as_secs()),
126+
}
127+
}
79128
}
80129
}
81130

0 commit comments

Comments
 (0)