Skip to content

Commit 91bdc88

Browse files
committed
eval: feed stdin from an empty temporary file to avoid hangs
1 parent a1c7d21 commit 91bdc88

11 files changed

Lines changed: 166 additions & 44 deletions

File tree

plugins/hls-eval-plugin/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,11 @@ N + M + 1 :: Nat
180180
"Other"
181181
```
182182

183-
IO expressions can also be evaluated but their output to stdout/stderr is NOT captured:
183+
IO expressions can also be evaluated and their output to `stdout`/`stderr` is captured:
184184

185185
```
186186
>>> print "foo"
187-
()
187+
"foo"
188188
```
189189

190190
### Properties

plugins/hls-eval-plugin/src/Ide/Plugin/Eval/Code.hs

Lines changed: 84 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ execStmtCaptureResult ::
117117
execStmtCaptureResult recorder stmt opts = do
118118
(result, (output, execResultE)) <-
119119
withCaptureResult recorder $
120-
withCaptureStdHandles opts $
120+
withCaptureStdHandles recorder opts $
121121
gStrictTry (execStmt stmt opts)
122122
case execResultE of
123123
Left exc ->
@@ -146,16 +146,19 @@ execStmtCaptureResult recorder stmt opts = do
146146
where
147147
trimmed = dropWhileEnd (== '\n') output
148148

149-
-- 'System.IO.Extra.withTempFile' is specialized to 'IO'.
149+
-- | Provide a fresh temporary file, removed once @k@ returns.
150+
-- ('System.IO.Extra.withTempFile' is specialized to 'IO'.)
151+
withTempFile_ :: (MonadIO m, MonadMask m) => (FilePath -> m b) -> m b
152+
withTempFile_ k =
153+
bracket (liftIO newTempFile) (liftIO . snd) (k . fst)
154+
155+
-- | Like 'withTempFile_', but also read the file's contents back once @k@
156+
-- | returns.
150157
withTempFile :: (MonadIO m, MonadMask m) => (FilePath -> m b) -> m (String, b)
151-
withTempFile k = do
152-
bracket
153-
(liftIO newTempFile)
154-
(\(_, purgeTempFile) -> liftIO purgeTempFile)
155-
(\(tempFile, _) -> do
156-
r <- k tempFile
157-
o <- liftIO $ readFile' tempFile
158-
pure (o, r))
158+
withTempFile k = withTempFile_ $ \tempFile -> do
159+
r <- k tempFile
160+
o <- liftIO $ readFile' tempFile
161+
pure (o, r)
159162

160163
-- | Capture the value the statement evaluates to (printed by GHCi via the
161164
-- interactive print function) by writing it to a temporary file.
@@ -187,31 +190,74 @@ withCaptureResult recorder action = withTempFile $ \resultTemp -> do
187190
-- may leak. base provides no per-thread standard handles, so this is
188191
-- unavoidable with this approach.
189192
withCaptureStdHandles ::
190-
ExecOptions
193+
Recorder (WithPriority Log)
194+
-> ExecOptions
191195
-> Ghc a
192196
-> Ghc (String, a)
193-
withCaptureStdHandles opts action = withTempFile $ \outputTemp -> do
197+
withCaptureStdHandles recorder opts action =
198+
-- @outputTemp@ collects the captured @stdout@/@stderr@; its contents are the
199+
-- returned 'String'. @inputTemp@ is a separate, empty file standing in for
200+
-- @stdin@: redirecting from it (rather than closing @stdin@) means reads in
201+
-- the evaluated code hit EOF immediately -- as if the program were run with
202+
-- @< /dev/null@ -- instead of raising "handle is closed". This is portable
203+
-- (no @/dev/null@/@NUL@ path) and keeps @stdin@ open so 'captureTeardown'
204+
-- can restore it cleanly.
205+
withTempFile $ \outputTemp ->
206+
withTempFile_ $ \inputTemp ->
207+
withRedirectedStdHandles recorder opts outputTemp inputTemp action
208+
209+
-- | Redirect the interpreted standard handles ('captureSetup') around @action@,
210+
-- restoring them ('captureTeardown') no matter how it terminates.
211+
withRedirectedStdHandles ::
212+
Recorder (WithPriority Log)
213+
-> ExecOptions
214+
-> FilePath -- ^ File the interpreted @stdout@/@stderr@ are written to.
215+
-> FilePath -- ^ File the interpreted @stdin@ is read from.
216+
-> Ghc a
217+
-> Ghc a
218+
withRedirectedStdHandles recorder opts outputTemp inputTemp action =
194219
bracket
195-
(execStmt (captureSetup outputTemp) opts)
196-
-- Restore the handles no matter how the statement terminated.
197-
(\_ -> execStmt captureTeardown opts)
220+
(execStmtCheck recorder "capture setup" (captureSetup outputTemp inputTemp) opts)
221+
(\_ -> execStmtCheck recorder "capture teardown" captureTeardown opts)
198222
(\_ -> action)
199223

200-
-- Open a temporary file and redirect the interpreted @stdout@/@stderr@ to
201-
-- it, saving the original handles in interactive bindings so 'captureTeardown'
202-
-- can restore them. Bound to a tuple (rather than evaluated as a bare
203-
-- expression) so GHCi does not pass it to the interactive print function.
204-
captureSetup :: FilePath -> String
224+
-- | Run an internal handle-redirection statement (capture setup/teardown) and
225+
-- log on failure.
226+
execStmtCheck ::
227+
Recorder (WithPriority Log)
228+
-> String
229+
-> String
230+
-> ExecOptions
231+
-> Ghc ()
232+
execStmtCheck recorder phase stmt opts = do
233+
result <- execStmt stmt opts
234+
case result of
235+
ExecComplete (Left err) _ ->
236+
logWith recorder Log.Warning $ LogEvalCaptureStdHandles phase (show err)
237+
_ -> pure ()
238+
239+
-- | Capture setup
240+
--
241+
-- Redirect the interpreted @stdout@/@stderr@ to a temporary file.
242+
--
243+
-- Redirect @stdin@ from an (empty) @inputTemp@ file.
244+
--
245+
-- The original handles are saved in interactive bindings so 'captureTeardown'
246+
-- can restore them.
247+
captureSetup :: FilePath -> FilePath -> String
205248
-- Squeeze into one line (executed by GHCi).
206-
captureSetup outputTemp = unwords
207-
[ "(__hls_captureHandle, __hls_savedStdout, __hls_savedStderr) <- do {"
208-
, " __hls_h <- System.IO.openFile", show outputTemp, "System.IO.WriteMode;"
209-
, " System.IO.hSetBuffering __hls_h System.IO.LineBuffering;"
210-
, " __hls_o <- GHC.IO.Handle.hDuplicate System.IO.stdout;"
211-
, " __hls_e <- GHC.IO.Handle.hDuplicate System.IO.stderr;"
212-
, " GHC.IO.Handle.hDuplicateTo __hls_h System.IO.stdout;"
213-
, " GHC.IO.Handle.hDuplicateTo __hls_h System.IO.stderr;"
214-
, " P.return (__hls_h, __hls_o, __hls_e);"
249+
captureSetup outputTemp inputTemp = unwords
250+
[ "(__hls_captureOut, __hls_captureIn, __hls_stdout, __hls_stderr, __hls_stdin) <- do {"
251+
, " __hls_co <- System.IO.openFile", show outputTemp, "System.IO.WriteMode;"
252+
, " System.IO.hSetBuffering __hls_co System.IO.LineBuffering;"
253+
, " __hls_ci <- System.IO.openFile", show inputTemp, "System.IO.ReadMode;"
254+
, " __hls_so <- GHC.IO.Handle.hDuplicate System.IO.stdout;"
255+
, " __hls_se <- GHC.IO.Handle.hDuplicate System.IO.stderr;"
256+
, " __hls_si <- GHC.IO.Handle.hDuplicate System.IO.stdin;"
257+
, " GHC.IO.Handle.hDuplicateTo __hls_co System.IO.stdout;"
258+
, " GHC.IO.Handle.hDuplicateTo __hls_co System.IO.stderr;"
259+
, " GHC.IO.Handle.hDuplicateTo __hls_ci System.IO.stdin;"
260+
, " P.return (__hls_co, __hls_ci, __hls_so, __hls_se, __hls_si);"
215261
, " }"
216262
]
217263

@@ -223,11 +269,15 @@ captureTeardown = unwords
223269
[ "__hls_restored <- do {"
224270
, " System.IO.hFlush System.IO.stdout;"
225271
, " System.IO.hFlush System.IO.stderr;"
226-
, " GHC.IO.Handle.hDuplicateTo __hls_savedStdout System.IO.stdout;"
227-
, " GHC.IO.Handle.hDuplicateTo __hls_savedStderr System.IO.stderr;"
228-
, " System.IO.hClose __hls_savedStdout;"
229-
, " System.IO.hClose __hls_savedStderr;"
230-
, " System.IO.hClose __hls_captureHandle;"
272+
-- stdin is an input handle, so it has nothing to flush.
273+
, " GHC.IO.Handle.hDuplicateTo __hls_stdout System.IO.stdout;"
274+
, " GHC.IO.Handle.hDuplicateTo __hls_stderr System.IO.stderr;"
275+
, " GHC.IO.Handle.hDuplicateTo __hls_stdin System.IO.stdin;"
276+
, " System.IO.hClose __hls_stdout;"
277+
, " System.IO.hClose __hls_stderr;"
278+
, " System.IO.hClose __hls_stdin;"
279+
, " System.IO.hClose __hls_captureOut;"
280+
, " System.IO.hClose __hls_captureIn;"
231281
, " }"
232282
]
233283

plugins/hls-eval-plugin/src/Ide/Plugin/Eval/Types.hs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ data Log
7676
| LogEvalImport String
7777
| LogEvalDeclaration String
7878
| LogEvalFailedSettingInteractivePrintFunction
79+
| LogEvalCaptureStdHandles String String
7980

8081
instance Pretty Log where
8182
pretty = \case
@@ -99,15 +100,18 @@ instance Pretty Log where
99100
LogEvalFlags flags -> "{:SET" <+> pretty flags
100101
LogEvalPreSetDynFlags dynFlags -> "pre set" <+> pretty (showDynFlags dynFlags)
101102
LogEvalParsedFlags eans -> "parsed flags" <+> viaShow (eans
102-
<&> (_1 %~ showDynFlags >>> _3 %~ prettyWarnings))
103+
<&> (_1 %~ showDynFlags >>> _3 %~ prettyWarnings))
103104
LogEvalPostSetDynFlags dynFlags -> "post set" <+> pretty (showDynFlags dynFlags)
104105
LogEvalStmtStart stmt -> "{STMT" <+> pretty stmt
105106
LogEvalStmtResult result -> "STMT}" <+> pretty result
106107
LogEvalImport stmt -> "{IMPORT" <+> pretty stmt
107108
LogEvalDeclaration stmt -> "{DECL" <+> pretty stmt
108109
LogEvalFailedSettingInteractivePrintFunction -> pretty $
109-
"Return value will not be captured: "
110-
++ "Failed setting the interactive print function."
110+
"Return value will not be captured: "
111+
++ "Failed setting the interactive print function."
112+
LogEvalCaptureStdHandles phase err -> pretty $
113+
"Redirecting stdout/stderr failed during "
114+
++ phase ++ ": " ++ err
111115

112116
-- | A thing with a location attached.
113117
data Located l a = Located {location :: l, located :: a}

plugins/hls-eval-plugin/test/Main.hs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,9 @@ tests =
128128
evalInFile "T8.hs" "-- >>> :t id" "-- id :: a -> a"
129129
evalInFile "T8.hs" "-- >>> :set -fprint-explicit-foralls\n-- >>> :t id" "-- id :: forall a. a -> a"
130130
, goldenWithEval "The default language extensions for the eval plugin are the same as those for ghci" "TSameDefaultLanguageExtensionsAsGhci" "hs"
131-
, goldenWithEval "Support IO expressions, capture and show stdout/stderr output" "TIO" "hs"
132-
, goldenWithEval "Support IO expressions, close handles on errors" "TIOError" "hs"
131+
, goldenWithEval "Support IO expressions (stdout), capture and show stdout output" "TIOStdout" "hs"
132+
, goldenWithEval "Support IO expressions (stderr), capture and show stderr output" "TIOStderr" "hs"
133+
, goldenWithEval "Support IO expressions (stdin)" "TIOStdin" "hs"
133134
, goldenWithEvalAndFs "Property checking" cabalProjectFS "TProperty" "hs"
134135
, knownBrokenInWindowsBeforeGHC912 "The output has path separators in it, which on Windows look different. Just skip it there" $
135136
goldenWithEvalAndFs' "Property checking with exception" cabalProjectFS "TPropertyError" "hs" $

plugins/hls-eval-plugin/test/testdata/TIOError.expected.hs renamed to plugins/hls-eval-plugin/test/testdata/TIOStderr.expected.hs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ module TIOError where
55

66
import Control.Exception
77

8-
{-
8+
{- We do not see the error value constructor.
9+
10+
>>> throwIO (TypeError "Doh")
11+
Doh
12+
-}
13+
14+
{- Do we capture `stderr` repeatedly?
15+
916
>>> throwIO (TypeError "Doh")
1017
Doh
1118
-}

plugins/hls-eval-plugin/test/testdata/TIOError.hs renamed to plugins/hls-eval-plugin/test/testdata/TIOStderr.hs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ module TIOError where
55

66
import Control.Exception
77

8-
{-
8+
{- We do not see the error value constructor.
9+
10+
>>> throwIO (TypeError "Doh")
11+
Doh
12+
-}
13+
14+
{- Do we capture `stderr` repeatedly?
15+
916
>>> throwIO (TypeError "Doh")
17+
Doh
1018
-}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- 1. Support `stdin`
2+
module TIO where
3+
4+
{- Feed `stdin` with empty data.
5+
6+
Avoid server hangs indefinitely, waiting for `stdin` to terminate.
7+
8+
Shows a clear error message.
9+
10+
>>> getLine >>= print
11+
<stdin>: hGetLine: end of file
12+
-}
13+
14+
{- Check that feeding `stdin` works repeatedly.
15+
16+
>>> getLine >>= print
17+
<stdin>: hGetLine: end of file
18+
-}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- 1. Support `stdin`
2+
module TIO where
3+
4+
{- Feed `stdin` with empty data.
5+
6+
Avoid server hangs indefinitely, waiting for `stdin` to terminate.
7+
8+
Shows a clear error message.
9+
10+
>>> getLine >>= print
11+
<stdin>: hGetLine: end of file
12+
-}
13+
14+
{- Check that feeding `stdin` works repeatedly.
15+
16+
>>> getLine >>= print
17+
<stdin>: hGetLine: end of file
18+
-}

plugins/hls-eval-plugin/test/testdata/TIO.expected.hs renamed to plugins/hls-eval-plugin/test/testdata/TIOStdout.expected.hs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,10 @@ Has a delay in order to show progress reporting.
1313
"ABC"
1414
"XYZ"
1515
-}
16+
17+
{- Check that capturing `stdout` works repeatedly.
18+
19+
>>> print "ABC" >> return "XYZ"
20+
"ABC"
21+
"XYZ"
22+
-}

plugins/hls-eval-plugin/test/testdata/TIO.hs renamed to plugins/hls-eval-plugin/test/testdata/TIOStdout.hs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,13 @@ import Control.Concurrent (threadDelay)
1010
Has a delay in order to show progress reporting.
1111
1212
>>> threadDelay 2000000 >> print "ABC" >> return "XYZ"
13+
"ABC"
14+
"XYZ"
15+
-}
16+
17+
{- Check that capturing `stdout` works repeatedly.
18+
19+
>>> print "ABC" >> return "XYZ"
20+
"ABC"
21+
"XYZ"
1322
-}

0 commit comments

Comments
 (0)