Skip to content

Commit eba83f5

Browse files
committed
perf: add persistent playwright executor daemon
Replace per-request tsx subprocess with a persistent Node.js daemon that maintains a warm CDP connection and uses esbuild for fast TypeScript transformation. Performance improvement: ~94% faster for simple operations - Before: ~750ms per request (tsx startup + module load + CDP connect) - After: ~45ms per request (reuses daemon + warm connection) The daemon is lazy-started on first request and communicates via Unix socket using newline-delimited JSON.
1 parent 046ef0b commit eba83f5

File tree

6 files changed

+431
-151
lines changed

6 files changed

+431
-151
lines changed

images/chromium-headful/Dockerfile

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,8 @@ RUN set -eux; \
276276
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
277277
fi
278278

279-
# Install TypeScript, Playwright, Patchright globally
280-
RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright tsx
279+
# Install TypeScript, Playwright, Patchright, esbuild globally
280+
RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright tsx esbuild
281281

282282
# setup desktop env & app
283283
ENV DISPLAY_NUM=1
@@ -303,8 +303,18 @@ COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/service
303303
COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api
304304
COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher
305305

306-
# Copy the Playwright executor runtime
307-
COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts
306+
# Copy and compile the Playwright daemon
307+
COPY server/runtime/playwright-daemon.ts /tmp/playwright-daemon.ts
308+
RUN esbuild /tmp/playwright-daemon.ts \
309+
--bundle \
310+
--platform=node \
311+
--target=node22 \
312+
--format=esm \
313+
--outfile=/usr/local/lib/playwright-daemon.js \
314+
--external:playwright-core \
315+
--external:patchright \
316+
--external:esbuild \
317+
&& rm /tmp/playwright-daemon.ts
308318

309319
RUN useradd -m -s /bin/bash kernel
310320

images/chromium-headless/image/Dockerfile

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ RUN set -eux; \
179179
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
180180
fi
181181

182-
# Install TypeScript, Playwright, Patchright globally
183-
RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright tsx
182+
# Install TypeScript, Playwright, Patchright, esbuild globally
183+
RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright tsx esbuild
184184

185185
ENV WITHDOCKER=true
186186

@@ -202,7 +202,17 @@ COPY images/chromium-headless/image/supervisor/services/ /etc/supervisor/conf.d/
202202
COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api
203203
COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher
204204

205-
# Copy the Playwright executor runtime
206-
COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts
205+
# Copy and compile the Playwright daemon
206+
COPY server/runtime/playwright-daemon.ts /tmp/playwright-daemon.ts
207+
RUN esbuild /tmp/playwright-daemon.ts \
208+
--bundle \
209+
--platform=node \
210+
--target=node22 \
211+
--format=esm \
212+
--outfile=/usr/local/lib/playwright-daemon.js \
213+
--external:playwright-core \
214+
--external:patchright \
215+
--external:esbuild \
216+
&& rm /tmp/playwright-daemon.ts
207217

208218
ENTRYPOINT [ "/usr/bin/wrapper.sh" ]

server/cmd/api/api/api.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"os"
8+
"os/exec"
89
"sync"
910
"time"
1011

@@ -44,6 +45,12 @@ type ApiService struct {
4445
// playwrightMu serializes Playwright code execution (only one execution at a time)
4546
playwrightMu sync.Mutex
4647

48+
// playwrightDaemonStarting is an atomic flag to prevent concurrent daemon starts
49+
playwrightDaemonStarting int32
50+
51+
// playwrightDaemonCmd holds the daemon process for cleanup
52+
playwrightDaemonCmd *exec.Cmd
53+
4754
// policy management
4855
policy *policy.Policy
4956
}

server/cmd/api/api/playwright.go

Lines changed: 130 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,141 @@
11
package api
22

33
import (
4+
"bufio"
45
"context"
56
"encoding/json"
67
"fmt"
8+
"net"
79
"os"
810
"os/exec"
11+
"sync/atomic"
912
"time"
1013

14+
"github.com/google/uuid"
1115
"github.com/onkernel/kernel-images/server/lib/logger"
1216
"github.com/onkernel/kernel-images/server/lib/oapi"
1317
)
1418

15-
// ExecutePlaywrightCode implements the Playwright code execution endpoint
19+
const (
20+
playwrightDaemonSocket = "/tmp/playwright-daemon.sock"
21+
playwrightDaemonScript = "/usr/local/lib/playwright-daemon.js"
22+
playwrightDaemonStartup = 5 * time.Second
23+
)
24+
25+
type playwrightDaemonRequest struct {
26+
ID string `json:"id"`
27+
Code string `json:"code"`
28+
TimeoutMs int `json:"timeout_ms,omitempty"`
29+
}
30+
31+
type playwrightDaemonResponse struct {
32+
ID string `json:"id"`
33+
Success bool `json:"success"`
34+
Result interface{} `json:"result,omitempty"`
35+
Error string `json:"error,omitempty"`
36+
Stack string `json:"stack,omitempty"`
37+
}
38+
39+
func (s *ApiService) ensurePlaywrightDaemon(ctx context.Context) error {
40+
log := logger.FromContext(ctx)
41+
42+
if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil {
43+
conn.Close()
44+
return nil
45+
}
46+
47+
if !atomic.CompareAndSwapInt32(&s.playwrightDaemonStarting, 0, 1) {
48+
deadline := time.Now().Add(playwrightDaemonStartup)
49+
for time.Now().Before(deadline) {
50+
if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil {
51+
conn.Close()
52+
return nil
53+
}
54+
time.Sleep(100 * time.Millisecond)
55+
}
56+
return fmt.Errorf("timeout waiting for daemon to start")
57+
}
58+
defer atomic.StoreInt32(&s.playwrightDaemonStarting, 0)
59+
60+
log.Info("starting playwright daemon")
61+
62+
cmd := exec.Command("node", playwrightDaemonScript)
63+
cmd.Stdout = os.Stdout
64+
cmd.Stderr = os.Stderr
65+
cmd.Env = os.Environ()
66+
67+
if err := cmd.Start(); err != nil {
68+
return fmt.Errorf("failed to start playwright daemon: %w", err)
69+
}
70+
71+
s.playwrightDaemonCmd = cmd
72+
73+
deadline := time.Now().Add(playwrightDaemonStartup)
74+
for time.Now().Before(deadline) {
75+
if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil {
76+
conn.Close()
77+
log.Info("playwright daemon started successfully")
78+
return nil
79+
}
80+
time.Sleep(100 * time.Millisecond)
81+
}
82+
83+
cmd.Process.Kill()
84+
return fmt.Errorf("playwright daemon failed to start within %v", playwrightDaemonStartup)
85+
}
86+
87+
func (s *ApiService) executeViaUnixSocket(ctx context.Context, code string, timeout time.Duration) (*playwrightDaemonResponse, error) {
88+
conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 2*time.Second)
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to connect to daemon: %w", err)
91+
}
92+
defer conn.Close()
93+
94+
if err := conn.SetDeadline(time.Now().Add(timeout + 5*time.Second)); err != nil {
95+
return nil, fmt.Errorf("failed to set deadline: %w", err)
96+
}
97+
98+
reqID := uuid.New().String()
99+
req := playwrightDaemonRequest{
100+
ID: reqID,
101+
Code: code,
102+
TimeoutMs: int(timeout.Milliseconds()),
103+
}
104+
105+
reqBytes, err := json.Marshal(req)
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to marshal request: %w", err)
108+
}
109+
reqBytes = append(reqBytes, '\n')
110+
111+
if _, err := conn.Write(reqBytes); err != nil {
112+
return nil, fmt.Errorf("failed to send request: %w", err)
113+
}
114+
115+
reader := bufio.NewReader(conn)
116+
respLine, err := reader.ReadBytes('\n')
117+
if err != nil {
118+
return nil, fmt.Errorf("failed to read response: %w", err)
119+
}
120+
121+
var resp playwrightDaemonResponse
122+
if err := json.Unmarshal(respLine, &resp); err != nil {
123+
return nil, fmt.Errorf("failed to parse response: %w", err)
124+
}
125+
126+
if resp.ID != reqID {
127+
return nil, fmt.Errorf("response ID mismatch: expected %s, got %s", reqID, resp.ID)
128+
}
129+
130+
return &resp, nil
131+
}
132+
16133
func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.ExecutePlaywrightCodeRequestObject) (oapi.ExecutePlaywrightCodeResponseObject, error) {
17-
// Serialize Playwright execution - only one execution at a time
18134
s.playwrightMu.Lock()
19135
defer s.playwrightMu.Unlock()
20136

21137
log := logger.FromContext(ctx)
22138

23-
// Validate request
24139
if request.Body == nil || request.Body.Code == "" {
25140
return oapi.ExecutePlaywrightCode400JSONResponse{
26141
BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
@@ -29,107 +144,42 @@ func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.Exe
29144
}, nil
30145
}
31146

32-
// Determine timeout (default to 60 seconds)
33147
timeout := 60 * time.Second
34148
if request.Body.TimeoutSec != nil && *request.Body.TimeoutSec > 0 {
35149
timeout = time.Duration(*request.Body.TimeoutSec) * time.Second
36150
}
37151

38-
// Create a temporary file for the user code
39-
tmpFile, err := os.CreateTemp("", "playwright-code-*.ts")
40-
if err != nil {
41-
log.Error("failed to create temp file", "error", err)
152+
if err := s.ensurePlaywrightDaemon(ctx); err != nil {
153+
log.Error("failed to ensure playwright daemon", "error", err)
42154
return oapi.ExecutePlaywrightCode500JSONResponse{
43155
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
44-
Message: fmt.Sprintf("failed to create temp file: %v", err),
156+
Message: fmt.Sprintf("failed to start playwright daemon: %v", err),
45157
},
46158
}, nil
47159
}
48-
tmpFilePath := tmpFile.Name()
49-
defer os.Remove(tmpFilePath) // Clean up the temp file
50-
51-
// Write the user code to the temp file
52-
if _, err := tmpFile.WriteString(request.Body.Code); err != nil {
53-
tmpFile.Close()
54-
log.Error("failed to write code to temp file", "error", err)
55-
return oapi.ExecutePlaywrightCode500JSONResponse{
56-
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
57-
Message: fmt.Sprintf("failed to write code to temp file: %v", err),
58-
},
59-
}, nil
60-
}
61-
tmpFile.Close()
62-
63-
// Create context with timeout
64-
execCtx, cancel := context.WithTimeout(ctx, timeout)
65-
defer cancel()
66-
67-
// Execute the Playwright code via the executor script
68-
cmd := exec.CommandContext(execCtx, "tsx", "/usr/local/lib/playwright-executor.ts", tmpFilePath)
69-
70-
output, err := cmd.CombinedOutput()
71160

161+
resp, err := s.executeViaUnixSocket(ctx, request.Body.Code, timeout)
72162
if err != nil {
73-
if execCtx.Err() == context.DeadlineExceeded {
74-
log.Error("playwright execution timed out", "timeout", timeout)
75-
success := false
76-
errorMsg := fmt.Sprintf("execution timed out after %v", timeout)
77-
return oapi.ExecutePlaywrightCode200JSONResponse{
78-
Success: success,
79-
Error: &errorMsg,
80-
}, nil
81-
}
82-
83163
log.Error("playwright execution failed", "error", err)
84-
85-
// Try to parse the error output as JSON
86-
var result struct {
87-
Success bool `json:"success"`
88-
Result interface{} `json:"result,omitempty"`
89-
Error string `json:"error,omitempty"`
90-
Stack string `json:"stack,omitempty"`
91-
}
92-
if jsonErr := json.Unmarshal(output, &result); jsonErr == nil {
93-
success := result.Success
94-
errorMsg := result.Error
95-
stderr := string(output)
96-
return oapi.ExecutePlaywrightCode200JSONResponse{
97-
Success: success,
98-
Error: &errorMsg,
99-
Stderr: &stderr,
100-
}, nil
101-
}
102-
103-
// If we can't parse the output, return a generic error
104-
success := false
105164
errorMsg := fmt.Sprintf("execution failed: %v", err)
106-
stderr := string(output)
107165
return oapi.ExecutePlaywrightCode200JSONResponse{
108-
Success: success,
166+
Success: false,
109167
Error: &errorMsg,
110-
Stderr: &stderr,
111168
}, nil
112169
}
113170

114-
// Parse successful output
115-
var result struct {
116-
Success bool `json:"success"`
117-
Result interface{} `json:"result,omitempty"`
118-
}
119-
if err := json.Unmarshal(output, &result); err != nil {
120-
log.Error("failed to parse playwright output", "error", err)
121-
success := false
122-
errorMsg := fmt.Sprintf("failed to parse output: %v", err)
123-
stdout := string(output)
171+
if !resp.Success {
172+
errorMsg := resp.Error
173+
stderr := resp.Stack
124174
return oapi.ExecutePlaywrightCode200JSONResponse{
125-
Success: success,
175+
Success: false,
126176
Error: &errorMsg,
127-
Stdout: &stdout,
177+
Stderr: &stderr,
128178
}, nil
129179
}
130180

131181
return oapi.ExecutePlaywrightCode200JSONResponse{
132-
Success: result.Success,
133-
Result: &result.Result,
182+
Success: true,
183+
Result: &resp.Result,
134184
}, nil
135185
}

0 commit comments

Comments
 (0)